Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: connect IDKit JS with SMS API #6

Merged
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
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.formatOnSave": true
}
3 changes: 2 additions & 1 deletion src/components/IDKitWidget/BaseWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import EnterPhoneState from './States/EnterPhoneState'
import VerifyCodeState from './States/VerifyCodeState'
import { AnimatePresence, motion } from 'framer-motion'
import QuestionMarkIcon from '../Icons/QuestionMarkIcon'
import useIDKitStore, { IDKITStage, IDKitStore } from '@/store/idkit'
import useIDKitStore, { IDKitStore } from '@/store/idkit'
import { ArrowLongLeftIcon, XMarkIcon } from '@heroicons/react/20/solid'
import { IDKITStage } from '@/types'

const getParams = ({ open, onOpenChange, stage, setStage }: IDKitStore) => ({
isOpen: open,
Expand Down
34 changes: 28 additions & 6 deletions src/components/IDKitWidget/States/EnterPhoneState.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
import { IDKITStage } from '@/types'
import { motion } from 'framer-motion'
import PhoneInput from '@/components/PhoneInput'
import WorldIDIcon from '@/components/WorldIDIcon'
import useIDKitStore, { IDKITStage, IDKitStore } from '@/store/idkit'
import useIDKitStore, { IDKitStore } from '@/store/idkit'
import { requestCode, isRequestCodeError } from '@/services/phone'

const getParams = ({ phoneNumber, setStage }: IDKitStore) => ({
const getParams = ({ processing, phoneNumber, actionId, setStage, setProcessing }: IDKitStore) => ({
processing,
phoneNumber,
actionId,
useWorldID: () => setStage(IDKITStage.WORLD_ID),
onSubmit: () => setStage(IDKITStage.ENTER_CODE),
onSubmit: async () => {
try {
setProcessing(true)
// FIXME: ph_distinct_id
await requestCode(phoneNumber, actionId, '')
setProcessing(false)
setStage(IDKITStage.ENTER_CODE)
} catch (error) {
setProcessing(false)
if (isRequestCodeError(error) && error.code !== 'server_error') {
// FIXME: Error toast here
console.error(error)
} else {
setStage(IDKITStage.ERROR)
}
}
},
})

const EnterPhoneState = () => {
const { phoneNumber, useWorldID, onSubmit } = useIDKitStore(getParams)
const { phoneNumber, processing, useWorldID, onSubmit } = useIDKitStore(getParams)

return (
<div className="space-y-6">
<div>
<p className="font-semibold text-2xl text-gray-900 text-center">
{/* TODO: Caption should be a config option */}
Verify your phone number for free gasless transactions.
</p>
<p className="text-gray-500 text-center mt-2">We&apos;ll take care of the rest!</p>
</div>
<div className="mt-2 space-y-2">
<PhoneInput />
<PhoneInput disabled={processing} />
<p className="text-xs text-center text-gray-400">
We&apos;ll call or text to confirm your number. No data is stored.
</p>
Expand All @@ -46,9 +67,10 @@ const EnterPhoneState = () => {
transition={{ layout: { duration: 0.15 } }}
onClick={onSubmit}
layoutId="submit-button"
disabled={!phoneNumber}
disabled={!phoneNumber || processing}
className="inline-flex items-center px-8 py-3 border border-transparent font-medium rounded-full shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-indigo-600"
>
{/* TODO: Loading state */}
<motion.span transition={{ layout: { duration: 0.15 } }} layoutId="button-text">
Continue
</motion.span>
Expand Down
35 changes: 29 additions & 6 deletions src/components/IDKitWidget/States/VerifyCodeState.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,52 @@
import { useRef } from 'react'
import { IDKITStage } from '@/types'
import { motion } from 'framer-motion'
import WorldIDIcon from '@/components/WorldIDIcon'
import SMSCodeInput from '@/components/SMSCodeInput'
import ResendButton from '@/components/ResendButton'
import useIDKitStore, { IDKITStage, IDKitStore } from '@/store/idkit'
import useIDKitStore, { IDKitStore } from '@/store/idkit'
import { verifyCode, isVerifyCodeError } from '@/services/phone'

const getParams = ({ code, setStage }: IDKitStore) => ({
const getParams = ({ processing, phoneNumber, code, actionId, setStage, setProcessing }: IDKitStore) => ({
processing,
phoneNumber,
code,
onSubmit: () => setStage(IDKITStage.SUCCESS),
actionId,
onSubmit: async () => {
try {
setProcessing(true)
// FIXME: Add ph_distinct_id
await verifyCode(phoneNumber, code, actionId, '')
setProcessing(false)
setStage(IDKITStage.SUCCESS)
} catch (error) {
setProcessing(false)
if (isVerifyCodeError(error)) {
// FIXME: show error toast here
console.error(error)
} else {
setStage(IDKITStage.ERROR)
}
}
},
useWorldID: () => setStage(IDKITStage.WORLD_ID),
})

const VerifyCodeState = () => {
const submitRef = useRef<HTMLButtonElement>(null)
const { code, onSubmit, useWorldID } = useIDKitStore(getParams)
const { processing, code, onSubmit, useWorldID } = useIDKitStore(getParams)

return (
<div className="space-y-6">
<div>
<p className="font-semibold text-2xl text-gray-900 text-center">
{/* TODO: Allow app to set this caption from settings */}
Verify your phone number for free gasless transactions.
</p>
<p className="text-gray-500 text-center mt-2">We&apos;ll take care of the rest!</p>
</div>
<form className="mt-2 space-y-2">
<SMSCodeInput submitRef={submitRef} />
<SMSCodeInput submitRef={submitRef} disabled={processing} />
<p className="text-xs text-center text-gray-400">
Did not receive a code? <ResendButton /> or{' '}
<button type="button" className="text-indigo-600 font-medium">
Expand Down Expand Up @@ -53,10 +75,11 @@ const VerifyCodeState = () => {
animate={{ opacity: code ? 1 : 0.4 }}
transition={{ layout: { duration: 0.15 } }}
onClick={onSubmit}
disabled={!code}
disabled={!code || processing}
ref={submitRef}
className="inline-flex w-full justify-center items-center px-8 py-4 border border-transparent font-medium rounded-2xl shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-indigo-600"
>
{/* FIXME: Loading state */}
<motion.span transition={{ layout: { duration: 0.15 } }} layoutId="button-text">
Continue
</motion.span>
Expand Down
7 changes: 4 additions & 3 deletions src/components/PhoneInput.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { phone } from 'phone'
import { useEffect, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import CountryCodeSelect from './CountryCodeSelect'
import useIDKitStore, { IDKitStore } from '@/store/idkit'

const getParams = ({ setPhoneNumber }: IDKitStore) => ({ setFullPhone: setPhoneNumber })

const PhoneInput = () => {
const PhoneInput = ({ disabled }: { disabled?: boolean }) => {
const { setFullPhone } = useIDKitStore(getParams)
const [countryCode, setCountryCode] = useState<string>('1')
const [phoneNumber, setPhoneNumber] = useState<string>('')
Expand Down Expand Up @@ -40,10 +40,11 @@ const PhoneInput = () => {
placeholder="Phone number"
onChange={e => setPhoneNumber(e.target.value)}
className="block w-full rounded-md border-transparent bg-transparent pl-24 focus:ring-transparent focus:border-transparent sm:text-sm"
disabled={disabled}
/>
</div>
</div>
)
}

export default PhoneInput
export default memo(PhoneInput)
3 changes: 2 additions & 1 deletion src/components/SMSCodeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const fillValues = (value: string): Array6<string> => {

const getParams = ({ setCode }: IDKitStore) => ({ setCode })

const SMSCodeInput = ({ submitRef }: { submitRef: RefObject<HTMLButtonElement> }) => {
const SMSCodeInput = ({ submitRef, disabled }: { submitRef: RefObject<HTMLButtonElement>; disabled?: boolean }) => {
const { setCode } = useIDKitStore(getParams)

const inputsRefs = useMemo(() => new Array(6).fill(null).map(() => createRef<HTMLInputElement>()), [])
Expand Down Expand Up @@ -154,6 +154,7 @@ const SMSCodeInput = ({ submitRef }: { submitRef: RefObject<HTMLButtonElement> }
onChange={event => onInputChange(event, i)}
onKeyDown={event => onInputKeyDown(event, i)}
className="w-12 h-14 border-0 bg-gray-100 rounded-xl text-center"
disabled={disabled}
/>
))}
</fieldset>
Expand Down
61 changes: 61 additions & 0 deletions src/services/phone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { IPhoneSignal } from '@/types'

const API_BASE_URL = 'https://developer.worldcoin.org/api/v1'

export async function requestCode(phone_number: string, action_id: string, ph_distinct_id: string) {
const res = await fetch(`${API_BASE_URL}/phone/request`, {
method: 'POST',
body: JSON.stringify({
phone_number,
action_id,
channel: 'sms', // FIXME
ph_distinct_id,
}),
})
if (res.ok) {
return
}
throw await res.json()
}

export async function verifyCode(phone_number: string, code: string, action_id: string, ph_distinct_id: string) {
const res = await fetch(`${API_BASE_URL}/phone/verify`, {
method: 'POST',
body: JSON.stringify({
phone_number,
code,
action_id,
ph_distinct_id,
}),
})
if (res.ok) {
return (await res.json()) as IPhoneSignal
}
throw await res.json()
}

export interface RequestCodeError {
code: 'timeout' | 'max_attempts' | 'server_error'
details: string
}

export function isRequestCodeError(error: unknown): error is RequestCodeError {
return (
typeof error === 'object' &&
error !== null &&
Object.prototype.hasOwnProperty.call(error as Record<string, unknown>, 'code')
)
}

export interface VerifyCodeError {
code: 'invalid_code'
details: string
}

export function isVerifyCodeError(error: unknown): error is VerifyCodeError {
return (
typeof error === 'object' &&
error !== null &&
Object.prototype.hasOwnProperty.call(error as Record<string, unknown>, 'code')
)
}
24 changes: 10 additions & 14 deletions src/store/idkit.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import { IDKITStage } from '@/types'
import create from 'zustand'

export enum IDKITStage {
ENTER_PHONE = 'ENTER_PHONE',
ENTER_CODE = 'ENTER_CODE',
WORLD_ID = 'WORLD_ID',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}

export type IDKitStore = {
open: boolean
phoneNumber: string
code: string
actionId: string
stage: IDKITStage
phoneNumber: string
processing: boolean // Whether an async request is being processed and we show a loading state in the UI
retryFlow: () => void
setCode: (code: string) => void
setOpen: (open: boolean) => void
onOpenChange: (open: boolean) => void
setStage: (stage: IDKITStage) => void
setActionId: (actionId: string) => void
setPhoneNumber: (phoneNumber: string) => void
setCode: (code: string) => void
setProcessing: (processing: boolean) => void
}

const useIDKitStore = create<IDKitStore>()(set => ({
Expand All @@ -29,16 +24,17 @@ const useIDKitStore = create<IDKitStore>()(set => ({
actionId: '',
phoneNumber: '',
stage: IDKITStage.ENTER_PHONE,
processing: false,
setOpen: open => set({ open }),
setPhoneNumber: phoneNumber => set({ phoneNumber }),
setCode: code => set({ code }),
setActionId: actionId => set({ actionId }),
setStage: stage => set({ stage }),
setActionId: (actionId: string) => set({ actionId }),
setPhoneNumber: (phoneNumber: string) => set({ phoneNumber }),
retryFlow: () => set({ stage: IDKITStage.ENTER_PHONE, phoneNumber: '' }),
setProcessing: (processing: boolean) => set({ processing }),
onOpenChange: open => {
if (open) return set({ open })

set({ open, phoneNumber: '', stage: IDKITStage.ENTER_PHONE })
set({ open, phoneNumber: '', code:'', processing:false, stage: IDKITStage.ENTER_PHONE })
},
}))

Expand Down
15 changes: 15 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@


export enum IDKITStage {
ENTER_PHONE = 'ENTER_PHONE',
ENTER_CODE = 'ENTER_CODE',
WORLD_ID = 'WORLD_ID',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}

export interface IPhoneSignal {
success: true;
nullifier_hash: string;
signature: string;
}