Skip to content

Commit

Permalink
✨ app: display panda data
Browse files Browse the repository at this point in the history
  • Loading branch information
dieguezguille committed Jan 10, 2025
1 parent ba286cc commit 4e3121d
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 16 deletions.
7 changes: 3 additions & 4 deletions src/components/card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,16 @@ export default function Card() {
if (isRevealing) return;
if (!passkey) return;
try {
const { isSuccess, data } = await refetchCard();
if (isSuccess && data.url) {
const { isSuccess } = await refetchCard();
if (isSuccess) {
setCardDetailsOpen(true);
return;
}
const result = await getKYCStatus();
if (result === "ok") {
await createCard();
const { data: card } = await refetchCard();
if (card?.url) setCardDetailsOpen(true);
if (card) setCardDetailsOpen(true);
} else {
resumeInquiry(result.inquiryId, result.sessionToken).catch(handleError);
}
Expand Down Expand Up @@ -206,7 +206,6 @@ export default function Card() {
</View>
</ScrollView>
<CardDetails
uri={cardDetails?.url}
open={cardDetailsOpen}
onClose={() => {
setCardDetailsOpen(false);
Expand Down
74 changes: 64 additions & 10 deletions src/components/card/CardDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { Snowflake, X } from "@tamagui/lucide-icons";
import { Copy, Snowflake, X } from "@tamagui/lucide-icons";
import { useToastController } from "@tamagui/toast";
import { useMutation, useQuery } from "@tanstack/react-query";
import { setStringAsync } from "expo-clipboard";
import React from "react";
import { ms } from "react-native-size-matters";
import { ScrollView, Sheet, Spinner, Square, Switch, XStack } from "tamagui";
import { nonEmpty, pipe, safeParse, string, url } from "valibot";
import { ScrollView, Sheet, Spinner, Square, Switch, XStack, YStack } from "tamagui";

import CardBack from "./CardBack";
import DismissableAlert from "./DismissableAlert";
import handleError from "../../utils/handleError";
import { decryptSecret } from "../../utils/panda";
import queryClient from "../../utils/queryClient";
import { getCard, setCardStatus } from "../../utils/server";
import Button from "../shared/Button";
import SafeView from "../shared/SafeView";
import Text from "../shared/Text";
import View from "../shared/View";

export default function CardDetails({ open, onClose, uri }: { open: boolean; onClose: () => void; uri?: string }) {
export default function CardDetails({ open, onClose }: { open: boolean; onClose: () => void }) {
const { data: alertShown } = useQuery({ queryKey: ["settings", "alertShown"] });
const { success, output } = safeParse(pipe(string(), nonEmpty("empty url"), url("bad url")), uri);
const toast = useToastController();
const { data: card, isFetching: isFetchingCard } = useQuery({ queryKey: ["card", "details"], queryFn: getCard });
const {
mutateAsync: changeCardStatus,
Expand All @@ -31,6 +33,10 @@ export default function CardDetails({ open, onClose, uri }: { open: boolean; onC
},
});
const displayStatus = isSettingCardStatus ? optimisticCardStatus : card?.status;
const cardNumber =
card && card.provider === "panda"
? decryptSecret(card.encryptedPan.data, card.encryptedPan.iv, card.secretKey)
: "";
return (
<Sheet
open={open}
Expand All @@ -56,8 +62,59 @@ export default function CardDetails({ open, onClose, uri }: { open: boolean; onC
<ScrollView>
<View fullScreen flex={1}>
<View gap="$s5" flex={1} padded>
{success && <CardBack uri={output} />}
{card && card.provider === "cryptomate" && <CardBack uri={card.url} />}

{card && card.provider === "panda" && (
<YStack
borderRadius="$s3"
borderWidth={1}
borderColor="$borderNeutralSoft"
backgroundColor="$uiNeutralPrimary"
padding="$s5"
paddingVertical="$s6"
justifyContent="space-between"
gap="$s6"
>
<XStack gap="$s4" alignItems="center">
<Text emphasized headline letterSpacing={2} fontFamily="$mono" color="$uiNeutralInversePrimary">
{cardNumber.match(/.{1,4}/g)?.join(" ") ?? ""}
</Text>
<Copy
hitSlop={20}
size={16}
color="$uiNeutralInversePrimary"
strokeWidth={2.5}
onPress={() => {
setStringAsync(cardNumber).catch(handleError);
toast.show("Copied to clipboard!");
}}
/>
</XStack>
<XStack gap="$s5" alignItems="center">
<XStack alignItems="center" gap="$s3">
<Text caption color="$uiNeutralInverseSecondary">
Expires
</Text>
<Text emphasized headline letterSpacing={2} fontFamily="$mono" color="$uiNeutralInversePrimary">
{`${card.expirationMonth}/${card.expirationYear}`}
</Text>
</XStack>
<XStack alignItems="center" gap="$s3">
<Text caption color="$uiNeutralInverseSecondary">
CVV&nbsp;
</Text>
<Text emphasized headline letterSpacing={2} fontFamily="$mono" color="$uiNeutralInversePrimary">
{decryptSecret(card.encryptedCvc.data, card.encryptedCvc.iv, card.secretKey)}
</Text>
</XStack>
</XStack>
<YStack>
<Text emphasized headline letterSpacing={2} color="$uiNeutralInversePrimary">
{card.displayName}
</Text>
</YStack>
</YStack>
)}
<XStack
width="100%"
paddingHorizontal="$s4"
Expand All @@ -84,7 +141,6 @@ export default function CardDetails({ open, onClose, uri }: { open: boolean; onC
{displayStatus === "FROZEN" ? "Unfreeze card" : "Freeze card"}
</Text>
</XStack>

<Switch
height={24}
pointerEvents="none"
Expand All @@ -102,16 +158,14 @@ export default function CardDetails({ open, onClose, uri }: { open: boolean; onC
/>
</Switch>
</XStack>

{success && alertShown && (
{card && card.provider === "cryptomate" && alertShown && (
<DismissableAlert
text="Manually add your card to Apple Pay & Google Pay to make contactless payments."
onDismiss={() => {
queryClient.setQueryData(["settings", "alertShown"], false);
}}
/>
)}

<Button
main
noFlex
Expand Down
42 changes: 42 additions & 0 deletions src/utils/panda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as crypto from "crypto"; // eslint-disable-line unicorn/prefer-node-protocol

const developmentPem = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCAP192809jZyaw62g/eTzJ3P9H
+RmT88sXUYjQ0K8Bx+rJ83f22+9isKx+lo5UuV8tvOlKwvdDS/pVbzpG7D7NO45c
0zkLOXwDHZkou8fuj8xhDO5Tq3GzcrabNLRLVz3dkx0znfzGOhnY4lkOMIdKxlQb
LuVM/dGDC9UpulF+UwIDAQAB
-----END PUBLIC KEY-----`;

function generateSessionId() {
const secretKey = crypto.randomUUID().replaceAll("-", "");
const secretKeyBase64 = Buffer.from(secretKey, "hex").toString("base64");
const secretKeyBase64Buffer = Buffer.from(secretKeyBase64, "utf8");
const secretKeyBase64BufferEncrypted = crypto.publicEncrypt(
{ key: developmentPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING },
secretKeyBase64Buffer,
);
return {
secretKey,
sessionId: secretKeyBase64BufferEncrypted.toString("base64"),
};
}

function decryptSecret(base64Secret: string, base64Iv: string, secretKey: string) {
if (!base64Secret) throw new Error("base64Secret is required");
if (!base64Iv) throw new Error("base64Iv is required");
if (!secretKey || !/^[0-9A-F]+$/i.test(secretKey)) {
throw new Error("secretKey must be a hex string");
}
const secret = Buffer.from(base64Secret, "base64");
const iv = Buffer.from(base64Iv, "base64");
const secretKeyBuffer = Buffer.from(secretKey, "hex");
const authTag = secret.subarray(-16);
const ciphertext = secret.subarray(0, -16);
const decipher = crypto.createDecipheriv("aes-128-gcm", secretKeyBuffer, iv);
decipher.setAutoPadding(false);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString("utf8");
}

export { generateSessionId, decryptSecret };
7 changes: 5 additions & 2 deletions src/utils/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { get as assert } from "react-native-passkeys";
import type { RegistrationResponseJSON } from "react-native-passkeys/build/ReactNativePasskeys.types";
import { check, number, parse, pipe, safeParse } from "valibot";

import { generateSessionId } from "./panda";
import queryClient from "./queryClient";

queryClient.setQueryDefaults<number | undefined>(["auth"], {
Expand Down Expand Up @@ -63,9 +64,11 @@ export async function verifyRegistration(attestation: RegistrationResponseJSON)

export async function getCard() {
await auth();
const response = await client.api.card.$get();
const session = generateSessionId();
const response = await client.api.card.$get({}, { headers: { SessionId: session.sessionId } });
if (!response.ok) throw new APIError(response.status, await response.json());
return response.json();
const card = await response.json();
return { ...card, ...session };
}

export async function createCard() {
Expand Down

0 comments on commit 4e3121d

Please sign in to comment.