@evervault/react-native 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +3 -2
  2. package/src/Card/Cvc.test.tsx +41 -0
  3. package/src/Card/Cvc.tsx +58 -0
  4. package/src/Card/Expiry.tsx +26 -0
  5. package/src/Card/Holder.tsx +27 -0
  6. package/src/Card/Number.test.tsx +76 -0
  7. package/src/Card/Number.tsx +54 -0
  8. package/src/Card/Root.test.tsx +341 -0
  9. package/src/Card/Root.tsx +150 -0
  10. package/src/Card/index.ts +28 -0
  11. package/src/Card/schema.ts +41 -0
  12. package/src/Card/types.ts +57 -0
  13. package/src/Card/utils.test.ts +271 -0
  14. package/src/Card/utils.ts +129 -0
  15. package/src/EvervaultProvider.test.tsx +24 -0
  16. package/src/EvervaultProvider.tsx +43 -0
  17. package/src/Input.test.tsx +420 -0
  18. package/src/Input.tsx +182 -0
  19. package/src/ThreeDSecure/Frame.test.tsx +87 -0
  20. package/src/ThreeDSecure/Frame.tsx +50 -0
  21. package/src/ThreeDSecure/Root.test.tsx +67 -0
  22. package/src/ThreeDSecure/Root.tsx +23 -0
  23. package/src/ThreeDSecure/config.ts +3 -0
  24. package/src/ThreeDSecure/context.ts +6 -0
  25. package/src/ThreeDSecure/event.ts +19 -0
  26. package/src/ThreeDSecure/index.ts +17 -0
  27. package/src/ThreeDSecure/session.test.ts +524 -0
  28. package/src/ThreeDSecure/session.ts +184 -0
  29. package/src/ThreeDSecure/types.ts +80 -0
  30. package/src/ThreeDSecure/useThreeDSecure.test.tsx +244 -0
  31. package/src/ThreeDSecure/useThreeDSecure.ts +64 -0
  32. package/src/__mocks__/NativeEvervault.ts +13 -0
  33. package/src/__mocks__/react-native-webview.tsx +6 -0
  34. package/src/context.ts +14 -0
  35. package/src/index.ts +21 -0
  36. package/src/sdk.test.ts +122 -0
  37. package/src/sdk.ts +71 -0
  38. package/src/specs/NativeEvervault.ts +67 -0
  39. package/src/useEvervault.test.tsx +31 -0
  40. package/src/useEvervault.ts +14 -0
  41. package/src/utils.ts +41 -0
@@ -0,0 +1,150 @@
1
+ import {
2
+ forwardRef,
3
+ PropsWithChildren,
4
+ useCallback,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useMemo,
8
+ useRef,
9
+ } from "react";
10
+ import { CardBrandName, CardConfig, CardPayload } from "./types";
11
+ import { DeepPartial, FormProvider, useForm } from "react-hook-form";
12
+ import { CardFormValues, getCardFormSchema } from "./schema";
13
+ import { zodResolver } from "@hookform/resolvers/zod";
14
+ import { useEvervault } from "../useEvervault";
15
+ import { formatPayload } from "./utils";
16
+ import { EvervaultInputContext, EvervaultInputContextValue } from "../Input";
17
+ import { EvervaultContextValue } from "../context";
18
+
19
+ const DEFAULT_ACCEPTED_BRANDS: CardBrandName[] = [];
20
+
21
+ export interface CardProps extends PropsWithChildren, CardConfig {
22
+ /**
23
+ * The default values to use for the form.
24
+ */
25
+ defaultValues?: {
26
+ name?: string;
27
+ number?: string;
28
+ expiry?: string;
29
+ cvc?: string;
30
+ };
31
+
32
+ /**
33
+ * Triggered whenever the component's state is updated.
34
+ */
35
+ onChange?(payload: CardPayload): void;
36
+
37
+ /**
38
+ * Triggered when a native error occurs.
39
+ */
40
+ onError?(error: Error): void;
41
+
42
+ /**
43
+ * The validation mode to use for the form.
44
+ *
45
+ * - `onChange`: Validate the form when the user changes a field.
46
+ * - `onBlur`: Validate the form when the user leaves a field.
47
+ * - `onTouched`: Validate the form when the user touches a field.
48
+ * - `all`: Validate the form when the user changes or leaves a field.
49
+ *
50
+ * @default "all"
51
+ */
52
+ validationMode?: "onChange" | "onBlur" | "onTouched" | "all";
53
+ }
54
+
55
+ export interface Card {
56
+ /**
57
+ * Resets the form to its default values and state.
58
+ */
59
+ reset(): void;
60
+ }
61
+
62
+ export const Card = forwardRef<Card, CardProps>(function Card(
63
+ {
64
+ children,
65
+ defaultValues,
66
+ onChange,
67
+ onError,
68
+ acceptedBrands = DEFAULT_ACCEPTED_BRANDS,
69
+ validationMode = "all",
70
+ },
71
+ ref
72
+ ) {
73
+ const evervault = useEvervault();
74
+
75
+ const resolver = useMemo(() => {
76
+ const schema = getCardFormSchema(acceptedBrands);
77
+ return zodResolver(schema);
78
+ }, [acceptedBrands]);
79
+
80
+ const methods = useForm<CardFormValues>({
81
+ defaultValues,
82
+ resolver,
83
+ mode: validationMode,
84
+ shouldUseNativeValidation: false,
85
+ });
86
+
87
+ const inputContext = useMemo<EvervaultInputContextValue>(
88
+ () => ({
89
+ validationMode,
90
+ }),
91
+ [validationMode]
92
+ );
93
+
94
+ // Use refs to prevent closures from being captured
95
+ const onChangeRef = useRef<typeof onChange>(onChange);
96
+ onChangeRef.current = onChange;
97
+ const onErrorRef = useRef<typeof onError>(onError);
98
+ onErrorRef.current = onError;
99
+
100
+ useEffect(() => {
101
+ if (!onChange) return;
102
+
103
+ let abortController: AbortController | undefined;
104
+ function handleChange(values: DeepPartial<CardFormValues>) {
105
+ if (abortController) {
106
+ abortController.abort();
107
+ }
108
+
109
+ abortController = new AbortController();
110
+ const signal = abortController.signal;
111
+
112
+ requestAnimationFrame(async () => {
113
+ try {
114
+ const payload = await formatPayload(values, {
115
+ encrypt: evervault.encrypt,
116
+ form: methods,
117
+ });
118
+ if (signal.aborted) return;
119
+ onChangeRef.current?.(payload);
120
+ } catch (error) {
121
+ onErrorRef.current?.(error as Error);
122
+ }
123
+ });
124
+ }
125
+
126
+ handleChange(methods.getValues());
127
+ const subscription = methods.watch(handleChange);
128
+ return () => subscription.unsubscribe();
129
+ }, [evervault.encrypt]);
130
+
131
+ useImperativeHandle(
132
+ ref,
133
+ useCallback(
134
+ () => ({
135
+ reset() {
136
+ methods.reset();
137
+ },
138
+ }),
139
+ []
140
+ )
141
+ );
142
+
143
+ return (
144
+ <FormProvider {...methods}>
145
+ <EvervaultInputContext.Provider value={inputContext}>
146
+ {children}
147
+ </EvervaultInputContext.Provider>
148
+ </FormProvider>
149
+ );
150
+ });
@@ -0,0 +1,28 @@
1
+ import { Card as CardRoot, type Card as CardRef } from "./Root";
2
+ import { CardHolder } from "./Holder";
3
+ import { CardExpiry } from "./Expiry";
4
+ import { CardCvc } from "./Cvc";
5
+ import { CardNumber } from "./Number";
6
+
7
+ export type { CardProps } from "./Root";
8
+ export type Card = CardRef;
9
+ export const Card = Object.assign(CardRoot, {
10
+ Holder: CardHolder,
11
+ Expiry: CardExpiry,
12
+ Cvc: CardCvc,
13
+ Number: CardNumber,
14
+ });
15
+
16
+ export type { CardHolderProps } from "./Holder";
17
+ export { CardHolder };
18
+
19
+ export type { CardExpiryProps } from "./Expiry";
20
+ export { CardExpiry };
21
+
22
+ export type { CardCvcProps } from "./Cvc";
23
+ export { CardCvc };
24
+
25
+ export type { CardNumberProps } from "./Number";
26
+ export { CardNumber };
27
+
28
+ export type { CardPayload, CardBrandName } from "./types";
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+ import {
3
+ validateNumber,
4
+ validateCVC,
5
+ validateExpiry,
6
+ } from "@evervault/card-validator";
7
+ import { CardBrandName } from "./types";
8
+ import { isAcceptedBrand } from "./utils";
9
+
10
+ export function getCardFormSchema(acceptedBrands: CardBrandName[]) {
11
+ return z.object({
12
+ name: z.string().min(1, "Missing name"),
13
+
14
+ number: z
15
+ .string()
16
+ .min(1, "Required")
17
+ .refine((value) => validateNumber(value).isValid, {
18
+ message: "Invalid card number",
19
+ })
20
+ .refine(
21
+ (value) => isAcceptedBrand(acceptedBrands, validateNumber(value)),
22
+ { message: "Brand not accepted" }
23
+ ),
24
+
25
+ expiry: z
26
+ .string()
27
+ .min(1, "Required")
28
+ .refine((value) => validateExpiry(value).isValid, {
29
+ message: "Invalid expiry",
30
+ }),
31
+
32
+ cvc: z
33
+ .string()
34
+ .min(1, "Required")
35
+ .refine((value) => validateCVC(value).isValid, {
36
+ message: "Invalid CVC",
37
+ }),
38
+ });
39
+ }
40
+
41
+ export type CardFormValues = z.infer<ReturnType<typeof getCardFormSchema>>;
@@ -0,0 +1,57 @@
1
+ export const CARD_BRAND_NAMES = [
2
+ "american-express",
3
+ "visa",
4
+ "mastercard",
5
+ "discover",
6
+ "jcb",
7
+ "diners-club",
8
+ "unionpay",
9
+ "maestro",
10
+ "mir",
11
+ "elo",
12
+ "hipercard",
13
+ "hiper",
14
+ "szep",
15
+ "uatp",
16
+ "rupay",
17
+ ] as const;
18
+
19
+ export type CardBrandName = (typeof CARD_BRAND_NAMES)[number];
20
+
21
+ export interface CardConfig {
22
+ /**
23
+ * The brands that are accepted by the card form.
24
+ * Pass an empty array to accept all brands.
25
+ *
26
+ * @default []
27
+ */
28
+ acceptedBrands?: CardBrandName[];
29
+ }
30
+
31
+ export type CardField = "name" | "number" | "expiry" | "cvc";
32
+
33
+ export interface CardExpiry {
34
+ month: string;
35
+ year: string;
36
+ }
37
+
38
+ export interface CardPayload {
39
+ card: {
40
+ name: string | null;
41
+ brand: CardBrandName | null;
42
+ localBrands: CardBrandName[];
43
+ number: string | null;
44
+ lastFour: string | null;
45
+ bin: string | null;
46
+ expiry: CardExpiry | null;
47
+ cvc: string | null;
48
+ };
49
+ isValid: boolean;
50
+ isComplete: boolean;
51
+ errors: {
52
+ name?: string;
53
+ number?: string;
54
+ expiry?: string;
55
+ cvc?: string;
56
+ };
57
+ }
@@ -0,0 +1,271 @@
1
+ import { encryptedValue } from "../__mocks__/NativeEvervault";
2
+ import { EncryptFn } from "../context";
3
+ import {
4
+ areValuesComplete,
5
+ formatExpiry,
6
+ formatPayload,
7
+ isAcceptedBrand,
8
+ } from "./utils";
9
+
10
+ describe("formatPayload", () => {
11
+ const setValue = vi.fn();
12
+
13
+ const encrypt = vi.fn<EncryptFn>(
14
+ () => Promise.resolve(encryptedValue) as any
15
+ );
16
+
17
+ it("should format the payload", async () => {
18
+ const result = await formatPayload(
19
+ {
20
+ name: "John Doe",
21
+ number: "4242424242424242",
22
+ expiry: "1234",
23
+ cvc: "123",
24
+ },
25
+ {
26
+ encrypt,
27
+ form: {
28
+ setValue,
29
+ formState: {
30
+ errors: {},
31
+ },
32
+ } as any,
33
+ }
34
+ );
35
+
36
+ expect(result).toEqual({
37
+ card: {
38
+ name: "John Doe",
39
+ brand: "visa",
40
+ localBrands: [],
41
+ bin: "42424242",
42
+ lastFour: "4242",
43
+ expiry: {
44
+ month: "12",
45
+ year: "34",
46
+ },
47
+ number: encryptedValue,
48
+ cvc: encryptedValue,
49
+ },
50
+ isComplete: true,
51
+ isValid: true,
52
+ errors: {},
53
+ });
54
+ });
55
+
56
+ it("should slice the CVC to 3 characters if brand isn't American Express", async () => {
57
+ await formatPayload(
58
+ {
59
+ name: "John Doe",
60
+ number: "4242424242424242",
61
+ expiry: "1234",
62
+ cvc: "1234",
63
+ },
64
+ {
65
+ encrypt,
66
+ form: {
67
+ setValue,
68
+ formState: {
69
+ errors: {},
70
+ },
71
+ } as any,
72
+ }
73
+ );
74
+
75
+ expect(setValue).toHaveBeenCalledWith("cvc", "123");
76
+ });
77
+
78
+ it("should return isValid=false if any errors are present", async () => {
79
+ const result = await formatPayload(
80
+ {
81
+ name: "",
82
+ },
83
+ {
84
+ encrypt,
85
+ form: {
86
+ setValue,
87
+ formState: {
88
+ errors: {
89
+ name: {
90
+ message: "Required",
91
+ },
92
+ },
93
+ },
94
+ } as any,
95
+ }
96
+ );
97
+
98
+ expect(result).toEqual({
99
+ card: {
100
+ name: "",
101
+ brand: null,
102
+ localBrands: [],
103
+ bin: null,
104
+ lastFour: null,
105
+ expiry: null,
106
+ number: null,
107
+ cvc: null,
108
+ },
109
+ isComplete: false,
110
+ isValid: false,
111
+ errors: {
112
+ name: "Required",
113
+ },
114
+ });
115
+ });
116
+
117
+ it("should ignore values if they are not provided", async () => {
118
+ const result = await formatPayload(
119
+ {
120
+ name: "John Doe",
121
+ },
122
+ {
123
+ encrypt,
124
+ form: {
125
+ setValue,
126
+ formState: {
127
+ errors: {},
128
+ },
129
+ } as any,
130
+ }
131
+ );
132
+
133
+ expect(result).toEqual({
134
+ card: {
135
+ name: "John Doe",
136
+ brand: null,
137
+ localBrands: [],
138
+ bin: null,
139
+ lastFour: null,
140
+ expiry: null,
141
+ number: null,
142
+ cvc: null,
143
+ },
144
+ isComplete: true,
145
+ isValid: true,
146
+ errors: {},
147
+ });
148
+ });
149
+ });
150
+
151
+ describe("areValuesComplete", () => {
152
+ it("should return true if all values are complete and valid", () => {
153
+ const result = areValuesComplete({
154
+ name: "John Doe",
155
+ number: "4242424242424242",
156
+ expiry: "1234",
157
+ cvc: "123",
158
+ });
159
+ expect(result).toBe(true);
160
+ });
161
+
162
+ it("should return true if all _provided_ values are complete", () => {
163
+ const result = areValuesComplete({
164
+ name: "John Doe",
165
+ });
166
+ expect(result).toBe(true);
167
+
168
+ const result2 = areValuesComplete({
169
+ number: "4242424242424242",
170
+ expiry: "1234",
171
+ cvc: "123",
172
+ });
173
+ expect(result2).toBe(true);
174
+ });
175
+
176
+ it("should return false if any value is missing", () => {
177
+ const result = areValuesComplete({
178
+ name: "John Doe",
179
+ number: "",
180
+ expiry: "",
181
+ cvc: "",
182
+ });
183
+ expect(result).toBe(false);
184
+ });
185
+
186
+ it("should return false if any value is invalid", () => {
187
+ const result = areValuesComplete({
188
+ number: "1234567890",
189
+ });
190
+ expect(result).toBe(false);
191
+
192
+ const result2 = areValuesComplete({
193
+ expiry: "123",
194
+ });
195
+ expect(result2).toBe(false);
196
+
197
+ const result3 = areValuesComplete({
198
+ cvc: "1",
199
+ });
200
+ expect(result3).toBe(false);
201
+ });
202
+ });
203
+
204
+ describe("isAcceptedBrand", () => {
205
+ it("should return true if no accepted brands are provided", () => {
206
+ const undef = isAcceptedBrand(undefined, {
207
+ brand: "visa",
208
+ bin: "123456",
209
+ lastFour: "1234",
210
+ localBrands: [],
211
+ isValid: true,
212
+ });
213
+ expect(undef).toBe(true);
214
+
215
+ const emptyArr = isAcceptedBrand([], {
216
+ brand: "visa",
217
+ bin: "123456",
218
+ lastFour: "1234",
219
+ localBrands: [],
220
+ isValid: true,
221
+ });
222
+ expect(emptyArr).toBe(true);
223
+ });
224
+
225
+ it("should return true if the brand is accepted", () => {
226
+ const result = isAcceptedBrand(["visa"], {
227
+ brand: "visa",
228
+ bin: "123456",
229
+ lastFour: "1234",
230
+ localBrands: [],
231
+ isValid: true,
232
+ });
233
+ expect(result).toBe(true);
234
+ });
235
+
236
+ it("should return true if the local brand is accepted", () => {
237
+ const result = isAcceptedBrand(["hiper"], {
238
+ brand: "visa",
239
+ bin: "123456",
240
+ lastFour: "1234",
241
+ localBrands: ["hiper"],
242
+ isValid: true,
243
+ });
244
+ expect(result).toBe(true);
245
+ });
246
+
247
+ it("should return false if the brand is not accepted", () => {
248
+ const result = isAcceptedBrand(["visa"], {
249
+ brand: "mastercard",
250
+ bin: "123456",
251
+ lastFour: "1234",
252
+ localBrands: [],
253
+ isValid: true,
254
+ });
255
+ expect(result).toBe(false);
256
+ });
257
+ });
258
+
259
+ describe("formatExpiry", () => {
260
+ it("should return null if the expiry date is invalid", () => {
261
+ const expiry = "123";
262
+ const formatted = formatExpiry(expiry);
263
+ expect(formatted).toBeNull();
264
+ });
265
+
266
+ it("should format the expiry date", () => {
267
+ const expiry = "1234";
268
+ const formatted = formatExpiry(expiry);
269
+ expect(formatted).toEqual({ month: "12", year: "34" });
270
+ });
271
+ });
@@ -0,0 +1,129 @@
1
+ import {
2
+ validateNumber,
3
+ validateExpiry,
4
+ validateCVC,
5
+ CardNumberValidationResult,
6
+ } from "@evervault/card-validator";
7
+ import type { CardBrandName, CardPayload } from "./types";
8
+ import { type CardFormValues } from "./schema";
9
+ import { DeepPartial, UseFormReturn } from "react-hook-form";
10
+ import { type Encrypted, sdk } from "../sdk";
11
+
12
+ export interface FormatPayloadContext {
13
+ form: UseFormReturn<CardFormValues>;
14
+ encrypt<T>(data: T): Promise<Encrypted<T>>;
15
+ }
16
+
17
+ export async function formatPayload(
18
+ values: DeepPartial<CardFormValues>,
19
+ context: FormatPayloadContext
20
+ ): Promise<CardPayload> {
21
+ const number = values.number?.replace(/\s/g, "") || "";
22
+
23
+ const {
24
+ brand,
25
+ localBrands,
26
+ bin,
27
+ lastFour,
28
+ isValid: isNumberValid,
29
+ } = validateNumber(number);
30
+
31
+ if (
32
+ number.length > 0 &&
33
+ brand !== "american-express" &&
34
+ values.cvc?.length === 4
35
+ ) {
36
+ context.form.setValue("cvc", values.cvc?.slice(0, 3));
37
+ }
38
+
39
+ const { cvc, isValid: isCvcValid } = validateCVC(values.cvc ?? "", number);
40
+
41
+ const formErrors = context.form.formState.errors;
42
+ const isValid = !Object.keys(formErrors).length;
43
+ const isComplete = areValuesComplete(values);
44
+
45
+ const errors: Record<string, string> = {};
46
+ if (formErrors.name?.message) {
47
+ errors.name = formErrors.name.message;
48
+ }
49
+ if (formErrors.number?.message) {
50
+ errors.number = formErrors.number.message;
51
+ }
52
+ if (formErrors.expiry?.message) {
53
+ errors.expiry = formErrors.expiry.message;
54
+ }
55
+ if (formErrors.cvc?.message) {
56
+ errors.cvc = formErrors.cvc.message;
57
+ }
58
+
59
+ return {
60
+ card: {
61
+ name: values.name ?? null,
62
+ brand,
63
+ localBrands,
64
+ bin,
65
+ lastFour,
66
+ expiry: formatExpiry(values.expiry ?? ""),
67
+ number: isNumberValid ? await context.encrypt(number) : null,
68
+ cvc: isCvcValid ? await context.encrypt(cvc ?? "") : null,
69
+ },
70
+ isComplete,
71
+ isValid: isValid && isComplete,
72
+ errors,
73
+ };
74
+ }
75
+
76
+ export function areValuesComplete(values: DeepPartial<CardFormValues>) {
77
+ if ("name" in values && !values.name?.length) {
78
+ return false;
79
+ }
80
+
81
+ if ("number" in values && !validateNumber(values.number ?? "").isValid) {
82
+ return false;
83
+ }
84
+
85
+ if ("expiry" in values && !validateExpiry(values.expiry ?? "").isValid) {
86
+ return false;
87
+ }
88
+
89
+ if (
90
+ "cvc" in values &&
91
+ !validateCVC(values.cvc ?? "", values.number).isValid
92
+ ) {
93
+ return false;
94
+ }
95
+
96
+ return true;
97
+ }
98
+
99
+ export function isAcceptedBrand(
100
+ acceptedBrands: CardBrandName[] | undefined,
101
+ cardNumberValidationResult: CardNumberValidationResult
102
+ ): boolean {
103
+ if (!acceptedBrands?.length) return true;
104
+
105
+ if (!cardNumberValidationResult.isValid) return false;
106
+ const { brand, localBrands } = cardNumberValidationResult;
107
+
108
+ const acceptedBrandsSet = new Set(acceptedBrands);
109
+
110
+ const isBrandAccepted = brand !== null && acceptedBrandsSet.has(brand);
111
+ const isLocalBrandAccepted = localBrands.some((localBrand) =>
112
+ acceptedBrandsSet.has(localBrand)
113
+ );
114
+
115
+ return isBrandAccepted || isLocalBrandAccepted;
116
+ }
117
+
118
+ export function formatExpiry(expiry: string) {
119
+ const parsedExpiry = validateExpiry(expiry);
120
+
121
+ if (!parsedExpiry.isValid) {
122
+ return null;
123
+ }
124
+
125
+ return {
126
+ month: parsedExpiry.month!,
127
+ year: parsedExpiry.year!,
128
+ };
129
+ }
@@ -0,0 +1,24 @@
1
+ import { PropsWithChildren, useContext } from "react";
2
+ import { EvervaultProvider } from "./EvervaultProvider";
3
+ import { renderHook } from "@testing-library/react-native";
4
+ import { EvervaultContext } from "./context";
5
+ import { sdk } from "./sdk";
6
+
7
+ it("renders context", () => {
8
+ const wrapper = ({ children }: PropsWithChildren) => (
9
+ <EvervaultProvider teamId="team_123" appId="app_123">
10
+ {children}
11
+ </EvervaultProvider>
12
+ );
13
+
14
+ const initSpy = vi.spyOn(sdk, "initialize");
15
+ const { result } = renderHook(() => useContext(EvervaultContext), {
16
+ wrapper,
17
+ });
18
+
19
+ expect(initSpy).toHaveBeenCalledWith("team_123", "app_123");
20
+ expect(result.current).toBeDefined();
21
+ expect(result.current?.appId).toBe("app_123");
22
+ expect(result.current?.teamId).toBe("team_123");
23
+ expect(result.current?.encrypt).toStrictEqual(expect.any(Function));
24
+ });