diff --git a/app.config.js b/app.config.js index abc980e..85f1fe5 100644 --- a/app.config.js +++ b/app.config.js @@ -62,6 +62,10 @@ export default { ], "expo-secure-store", ["@config-plugins/react-native-dynamic-app-icon", appIconConfig], + [ + "expo-local-authentication", + { faceIDPermission: "Allow $(PRODUCT_NAME) to use Face ID." }, + ], ], }, }; diff --git a/eas.json b/eas.json index def632f..2c0d93a 100644 --- a/eas.json +++ b/eas.json @@ -13,7 +13,8 @@ "production": { "env": { "EXPO_PUBLIC_API_BASE": "https://hcb.hackclub.com/api/v4", - "EXPO_PUBLIC_CLIENT_ID": "yt8JHmPDmmYYLUmoEiGtocYwg5fSOGCrcIY3G-vkMRs" + "EXPO_PUBLIC_CLIENT_ID": "yt8JHmPDmmYYLUmoEiGtocYwg5fSOGCrcIY3G-vkMRs", + "EXPO_PUBLIC_STRIPE_API_KEY": "pk_live_UAjIP1Kss29XZ6tW0MFWkjUQ" // "pk" stands for "publishable key" you wannabe hacker } } }, diff --git a/package-lock.json b/package-lock.json index 0875e27..2f12851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "expo-image-picker": "~14.7.1", "expo-linear-gradient": "~12.7.2", "expo-linking": "~6.2.2", + "expo-local-authentication": "~13.8.0", "expo-notifications": "~0.27.6", "expo-secure-store": "~12.8.1", "expo-splash-screen": "~0.26.4", @@ -9175,6 +9176,17 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-local-authentication": { + "version": "13.8.0", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-13.8.0.tgz", + "integrity": "sha512-h0YA7grVdo3834AS70EUCsalaXrrEnoq+yTvIhRTxiPmzWxUv7rNo5ff+XsIEYNElKPmT/wh/xPV1yo3l3fhGg==", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-manifests": { "version": "0.13.2", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.13.2.tgz", diff --git a/package.json b/package.json index 86955ca..581d810 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "react-native-svg": "14.1.0", "react-native-web": "^0.19.8", "swr": "^2.2.1", - "ts-pattern": "^5.0.8" + "ts-pattern": "^5.0.8", + "expo-local-authentication": "~13.8.0" }, "devDependencies": { "@babel/core": "^7.22.11", diff --git a/src/components/PaymentCard.tsx b/src/components/PaymentCard.tsx index 48e6e6a..7fd2d95 100644 --- a/src/components/PaymentCard.tsx +++ b/src/components/PaymentCard.tsx @@ -7,7 +7,9 @@ import { Text, View, ViewProps } from "react-native"; // } from "react-native-reanimated"; import Card from "../lib/types/Card"; +import { CardDetails } from "../lib/useStripeCardDetails"; import { palette } from "../theme"; +import { redactedCardNumber, renderCardNumber } from "../util"; import CardChip from "./cards/CardChip"; @@ -21,8 +23,9 @@ import CardChip from "./cards/CardChip"; export default function PaymentCard({ card, + details, ...props -}: ViewProps & { card: Card }) { +}: ViewProps & { card: Card; details?: CardDetails }) { const { colors: themeColors, dark } = useTheme(); return ( @@ -34,7 +37,7 @@ export default function PaymentCard({ borderRadius: 16, flexDirection: "column", justifyContent: "flex-end", - alignItems: "flex-start", + alignItems: "stretch", position: "relative", borderWidth: 1, borderColor: dark ? palette.slate : palette.muted, @@ -53,6 +56,7 @@ export default function PaymentCard({ paddingVertical: 6, borderColor: "rgb(91, 192, 222)", borderWidth: 1, + alignSelf: "flex-start", }} > - ···· ···· ···· {card.last4 || "····"} - - - {card.organization.name} + {details + ? renderCardNumber(details.number) + : redactedCardNumber(card.last4)} + + + {card.organization.name} + + + Exp + + {card.exp_month?.toLocaleString("en-US", { + minimumIntegerDigits: 2, + })} + /{card.exp_year?.toString().slice(-2)} + + + + CVC + + {details?.cvc || "***"} + + + ); } diff --git a/src/lib/types/Card.ts b/src/lib/types/Card.ts index bd8cd54..afa4a0d 100644 --- a/src/lib/types/Card.ts +++ b/src/lib/types/Card.ts @@ -3,6 +3,8 @@ import Organization from "./Organization"; export default interface Card extends HcbApiObject<"crd"> { last4?: string; + exp_month?: number; + exp_year?: number; type: "virtual" | "physical"; status: "inactive" | "frozen" | "active" | "canceled"; name?: string; diff --git a/src/lib/useStripeCardDetails.ts b/src/lib/useStripeCardDetails.ts new file mode 100644 index 0000000..3ae3f0d --- /dev/null +++ b/src/lib/useStripeCardDetails.ts @@ -0,0 +1,79 @@ +import * as LocalAuthentication from "expo-local-authentication"; +import ky from "ky"; +import { useEffect, useState } from "react"; + +import useClient from "./client"; + +export interface CardDetails { + number: string; + cvc: string; + exp_month: number; + exp_year: number; +} + +export default function useStripeCardDetails(cardId: string) { + const hcb = useClient(); + const [revealed, setRevealed] = useState(false); + const [loading, setLoading] = useState(false); + const [details, setDetails] = useState(undefined); + + useEffect(() => { + (async () => { + if (revealed) { + try { + setLoading(true); + + const { private_nonce, public_nonce } = await ky + .post("https://api.stripe.com/v1/ephemeral_key_nonces", { + headers: { + Authorization: `Bearer ${process.env.EXPO_PUBLIC_STRIPE_API_KEY}`, + }, + }) + .json<{ private_nonce: string; public_nonce: string }>(); + + const { ephemeralKeySecret, stripe_id: stripeId } = await hcb( + `cards/${cardId}/ephemeral_keys?nonce=${public_nonce}`, + ).json<{ ephemeralKeySecret: string; stripe_id: string }>(); + + const { number, cvc, exp_month, exp_year } = await ky( + `https://api.stripe.com/v1/issuing/cards/${stripeId}`, + { + searchParams: { + "expand[0]": "number", + "expand[1]": "cvc", + ephemeral_key_private_nonce: private_nonce, + }, + headers: { + Authorization: `Bearer ${ephemeralKeySecret}`, + "Stripe-Version": "2020-03-02", + }, + }, + ).json(); + + setDetails({ cvc, number, exp_month, exp_year }); + } finally { + setLoading(false); + } + } else { + setDetails(undefined); + } + })(); + }, [revealed, cardId, setDetails, hcb]); + + return { + details, + revealed, + loading, + toggle: async () => { + if (revealed) { + setRevealed(false); + } else { + const result = await LocalAuthentication.authenticateAsync(); + + if (result.success) { + setRevealed(true); + } + } + }, + }; +} diff --git a/src/pages/card.tsx b/src/pages/card.tsx index ecb6992..5a6a2c2 100644 --- a/src/pages/card.tsx +++ b/src/pages/card.tsx @@ -19,7 +19,9 @@ import useClient from "../lib/client"; import { CardsStackParamList } from "../lib/NavigatorParamList"; import Card from "../lib/types/Card"; import ITransaction from "../lib/types/Transaction"; +import useStripeCardDetails from "../lib/useStripeCardDetails"; import { palette } from "../theme"; +import { renderMoney } from "../util"; type Props = NativeStackScreenProps; @@ -32,6 +34,13 @@ export default function CardPage({ const { colors: themeColors } = useTheme(); const hcb = useClient(); + const { + details, + toggle: toggleDetailsRevealed, + revealed: detailsRevealed, + loading: detailsLoading, + } = useStripeCardDetails(_card.id); + const { data: card } = useSWR(`cards/${_card.id}`, { fallbackData: _card, }); @@ -78,7 +87,7 @@ export default function CardPage({ contentContainerStyle={{ padding: 20, paddingBottom: tabBarHeight + 20 }} scrollIndicatorInsets={{ bottom: tabBarHeight }} > - + {card.status != "canceled" && ( - {/* {card.type == "virtual" && ( + {card.type == "virtual" && ( - )} */} + )} )} @@ -133,17 +143,41 @@ export default function CardPage({ ) : ( <> - - Transactions - + + Transactions + + {card.total_spent_cents && ( + + + {renderMoney(card.total_spent_cents)} + {" "} + spent + + )} + {transactions.data.map((transaction, index) => (