diff --git a/README.md b/README.md index 7505d1c..24765c5 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,16 @@ yarn add @authsignal/react ``` ## Usage -Add the `Authsignal` component to your app. Generally, this should be placed at the root of your app. +Render the `AuthsignalProvider` component at the root of your app. ```jsx import { Authsignal } from '@authsignal/react'; function App() { return ( -
+ - -
+ ); } ``` @@ -52,9 +51,9 @@ export function Checkout() { const data = await response.json(); - if (data.challengeOptions) { + if (data.token) { startChallenge({ - challengeOptions: data.challengeOptions, + token: data.token, onChallengeSuccess: ({ token }) => { // Challenge was successful }, @@ -95,10 +94,10 @@ export function Checkout() { const data = await response.json(); - if (data.challengeOptions) { + if (data.token) { try { const { token } = await startChallengeAsync({ - challengeOptions: data.challengeOptions, + token: data.token, }); // Challenge was successful diff --git a/package.json b/package.json index 4f4f1f7..24965db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@authsignal/react", - "version": "0.0.15", + "version": "0.1.0", "description": "", "keywords": [ "authsignal", diff --git a/src/authsignal-provider.tsx b/src/authsignal-provider.tsx index 7122f6e..21a2d70 100644 --- a/src/authsignal-provider.tsx +++ b/src/authsignal-provider.tsx @@ -1,24 +1,25 @@ import React from "react"; -import { AuthsignalProps } from "./types"; - -type AuthsignalProviderProps = { - children: React.ReactNode; -} & AuthsignalProps; - -export const AuthsignalContext = React.createContext< - Pick | undefined ->(undefined); +import { Challenge } from "./components/challenge/challenge"; +import { AuthsignalProviderProps, ChallengeProps } from "./types"; +import { AuthsignalContext } from "./hooks/use-authsignal-context"; export function AuthsignalProvider({ children, tenantId, - baseUrl, + baseUrl = "https://api.authsignal.com/v1", appearance, }: AuthsignalProviderProps) { + const [challenge, setChallenge] = React.useState( + undefined, + ); + return ( - + {children} + {challenge && } ); } diff --git a/src/authsignal.tsx b/src/authsignal.tsx deleted file mode 100644 index 78724bd..0000000 --- a/src/authsignal.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; - -import { AuthsignalProvider } from "./authsignal-provider"; -import { Challenge } from "./components/challenge/challenge"; -import { useAuthsignal } from "./use-authsignal"; -import { AuthsignalProps } from "./types"; - -export function Authsignal(props: AuthsignalProps) { - const { challenge } = useAuthsignal(); - - return ( - - {challenge && } - - ); -} diff --git a/src/hooks/use-authsignal-context.ts b/src/hooks/use-authsignal-context.ts index cfc4664..70ce6a2 100644 --- a/src/hooks/use-authsignal-context.ts +++ b/src/hooks/use-authsignal-context.ts @@ -1,6 +1,15 @@ import React from "react"; +import { AuthsignalProviderProps, ChallengeProps } from "../types"; -import { AuthsignalContext } from "../authsignal-provider"; +export const AuthsignalContext = React.createContext< + | (Pick & { + challenge: ChallengeProps | undefined; + setChallenge: React.Dispatch< + React.SetStateAction + >; + }) + | undefined +>(undefined); export function useAuthsignalContext() { const context = React.useContext(AuthsignalContext); diff --git a/src/index.ts b/src/index.ts index dfc1d94..61d0f03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import "./main.css"; -export { Authsignal } from "./authsignal"; +export { AuthsignalProvider } from "./authsignal-provider"; export { useAuthsignal, ChallengeError } from "./use-authsignal"; -export { Appearance, AuthsignalProps, ChallengeOptions } from "./types"; +export { Appearance, AuthsignalProviderProps } from "./types"; diff --git a/src/types.ts b/src/types.ts index 02730e6..067a829 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,7 +17,7 @@ export type Appearance = { variables?: AppearanceVariables; }; -export type AuthsignalProps = { +export type AuthsignalProviderProps = { baseUrl?: | "https://api.authsignal.com/v1" | "https://au.api.authsignal.com/v1" @@ -25,6 +25,7 @@ export type AuthsignalProps = { | (string & {}); tenantId: string; appearance?: Appearance; + children: React.ReactNode; }; export const VerificationMethod = { @@ -47,13 +48,18 @@ export type ChallengeOptions = { }; }; -export type ChallengeProps = { - challengeOptions: ChallengeOptions; +type ChallengeCallbacks = { onChallengeSuccess?: (params: { token: string }) => void; onCancel?: () => void; onTokenExpired?: () => void; }; -export type StartChallengeOptions = ChallengeProps; +export type ChallengeProps = { + challengeOptions: ChallengeOptions; +} & ChallengeCallbacks; -export type StartChallengeAsyncOptions = { challengeOptions: ChallengeOptions }; +export type StartChallengeOptions = { token: string } & ChallengeCallbacks; + +export type StartChallengeAsyncOptions = { + token: string; +}; diff --git a/src/use-authsignal.tsx b/src/use-authsignal.tsx index ffd4279..7632753 100644 --- a/src/use-authsignal.tsx +++ b/src/use-authsignal.tsx @@ -1,29 +1,14 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { + ChallengeOptions, ChallengeProps, StartChallengeAsyncOptions, StartChallengeOptions, } from "./types"; +import { useAuthsignalContext } from "./hooks/use-authsignal-context"; const ANIMATION_DURATION = 500; -type Listener = (payload: T) => void; - -class EventEmitter { - private listeners: Listener[] = []; - - subscribe(listener: Listener): () => void { - this.listeners.push(listener); - return () => { - this.listeners = this.listeners.filter((l) => l !== listener); - }; - } - - emit(payload: T) { - this.listeners.forEach((listener) => listener(payload)); - } -} - type ChallengeErrorCodes = | "USER_CANCELED" | "TOKEN_EXPIRED" @@ -39,22 +24,38 @@ export class ChallengeError extends Error { } } -let memoryChallengeState: ChallengeProps | undefined; - -const challengeEmitter = new EventEmitter(); +type InitResponse = { + challengeOptions: ChallengeOptions; +}; export function useAuthsignal() { - const [challenge, setChallenge] = useState( - memoryChallengeState, - ); + const { baseUrl, setChallenge, challenge } = useAuthsignalContext(); + + const getChallengeOptions = useCallback( + async ({ token }: { token: string }) => { + const initRequest = await fetch(`${baseUrl}/client/init`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({}), + }); + + if (!initRequest.ok) { + return; + } + + const initResponse: InitResponse = await initRequest.json(); - useEffect(() => { - const unsubscribe = challengeEmitter.subscribe(setChallenge); - return () => unsubscribe(); - }, []); + return initResponse.challengeOptions; + }, + [baseUrl], + ); const startChallenge = useCallback( - (options: StartChallengeOptions) => { + async (options: StartChallengeOptions) => { if (challenge) { throw new ChallengeError( "EXISTING_CHALLENGE", @@ -62,6 +63,15 @@ export function useAuthsignal() { ); } + const challengeOptions = await getChallengeOptions({ + token: options.token, + }); + + if (!challengeOptions) { + options.onTokenExpired?.(); + return; + } + const previouslyFocusedElement = document.activeElement as HTMLElement | null; @@ -70,7 +80,7 @@ export function useAuthsignal() { }; const newChallenge: ChallengeProps = { - ...options, + challengeOptions, onChallengeSuccess: ({ token }) => { setTimeout(() => { @@ -78,8 +88,7 @@ export function useAuthsignal() { options.onChallengeSuccess({ token }); } - memoryChallengeState = undefined; - challengeEmitter.emit(memoryChallengeState); + setChallenge(undefined); }, ANIMATION_DURATION); }, @@ -89,8 +98,7 @@ export function useAuthsignal() { options.onCancel(); } - memoryChallengeState = undefined; - challengeEmitter.emit(memoryChallengeState); + setChallenge(undefined); returnFocus(); }, ANIMATION_DURATION); @@ -102,22 +110,20 @@ export function useAuthsignal() { options.onTokenExpired(); } - memoryChallengeState = undefined; - challengeEmitter.emit(memoryChallengeState); + setChallenge(undefined); returnFocus(); }, ANIMATION_DURATION); }, }; - memoryChallengeState = newChallenge; - challengeEmitter.emit(memoryChallengeState); + setChallenge(newChallenge); }, - [challenge], + [challenge, getChallengeOptions, setChallenge], ); const startChallengeAsync = useCallback( - (options: StartChallengeAsyncOptions) => { + async (options: StartChallengeAsyncOptions) => { if (challenge) { throw new ChallengeError( "EXISTING_CHALLENGE", @@ -125,6 +131,14 @@ export function useAuthsignal() { ); } + const challengeOptions = await getChallengeOptions({ + token: options.token, + }); + + if (!challengeOptions) { + throw new ChallengeError("TOKEN_EXPIRED", "Challenge token expired."); + } + const previouslyFocusedElement = document.activeElement as HTMLElement | null; @@ -134,14 +148,13 @@ export function useAuthsignal() { return new Promise<{ token: string }>((resolve, reject) => { const newChallenge: ChallengeProps = { - ...options, + challengeOptions, onChallengeSuccess: ({ token }) => { setTimeout(() => { resolve({ token }); - memoryChallengeState = undefined; - challengeEmitter.emit(memoryChallengeState); + setChallenge(undefined); }, ANIMATION_DURATION); }, @@ -154,8 +167,7 @@ export function useAuthsignal() { ), ); - memoryChallengeState = undefined; - challengeEmitter.emit(memoryChallengeState); + setChallenge(undefined); returnFocus(); }, ANIMATION_DURATION); @@ -167,19 +179,17 @@ export function useAuthsignal() { new ChallengeError("TOKEN_EXPIRED", "Challenge token expired."), ); - memoryChallengeState = undefined; - challengeEmitter.emit(memoryChallengeState); + setChallenge(undefined); returnFocus(); }, ANIMATION_DURATION); }, }; - memoryChallengeState = newChallenge; - challengeEmitter.emit(memoryChallengeState); + setChallenge(newChallenge); }); }, - [challenge], + [challenge, getChallengeOptions, setChallenge], ); return { challenge, startChallenge, startChallengeAsync };