Skip to content

Commit

Permalink
✨ Integrate privy flow inside SDK Modal + remove webauthn requirement
Browse files Browse the repository at this point in the history
  • Loading branch information
KONFeature committed Dec 18, 2024
1 parent 5070201 commit bb2b823
Show file tree
Hide file tree
Showing 9 changed files with 152 additions and 142 deletions.
4 changes: 1 addition & 3 deletions packages/wallet/app/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"description_reward": "{{ productName }} pays you directly into your **wallets** for the value you create through actions on this site, such as clicks, registrations or purchases.",
"primaryAction": "I create my wallet under 30sec",
"secondaryAction": "I already have a wallet",
"privyAction": "Connect via Privy",
"title": "Connection"
},
"success": "Connection successful"
Expand Down Expand Up @@ -238,9 +239,6 @@
"welcome": {
"title": "Welcome in your wallet",
"text": "This wallet will enable you to collect all the rewards and much more."
},
"errors": {
"webauthnNotSupported": "Open this page on your <strong>default browser</strong>, or use a compatible browser to create your wallet. Be sure to use the <strong>latest version</strong> of your browser."
}
}
}
4 changes: 1 addition & 3 deletions packages/wallet/app/i18n/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"description_reward": "Pour tout achat sur le site **{{ productName }}**, je reçois **{{ estimatedReward }}€** directement sur mon porte-monnaie, utilisable où je veux.",
"primaryAction": "Je crée mon porte-monnaie en moins de 30 secondes",
"secondaryAction": "J'ai déjà un porte-monnaie",
"privyAction": "Connection via Privy",
"title": "Connexion"
},
"success": "Connexion réussie"
Expand Down Expand Up @@ -239,9 +240,6 @@
"welcome": {
"title": "Bienvenue dans votre porte-monnaie",
"text": "Ce porte-monnaie vous permettra de collecter toutes les récompenses et bien plus encore."
},
"errors": {
"webauthnNotSupported": "Ouvrez cette page sur votre <strong>navigateur par défaut</strong> ou utilisez un navigateur compatible pour créer votre portefeuille. Assurez-vous d'utiliser la <strong>dernière version</strong> de votre navigateur."
}
}
}
Original file line number Diff line number Diff line change
@@ -1,101 +1,18 @@
import { crossAppClient } from "@/context/blockchain/privy-cross-client";
import { authenticatedBackendApi } from "@/context/common/backendClient";
import { sdkSessionAtom, sessionAtom } from "@/module/common/atoms/session";
import { isCrossAppWalletLoggedInQuery } from "@/module/common/hook/crossAppPrivyHooks";
import { jotaiStore } from "@module/atoms/store";
import { usePrivyCrossAppAuthenticate } from "@/module/common/hook/crossAppPrivyHooks";
import { Button } from "@module/component/Button";
import { trackEvent } from "@module/utils/trackEvent";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type Address, type Hex, stringToHex } from "viem";
import { generatePrivateKey } from "viem/accounts";

/**
* Do an ecdsa login, and chain the steps
* - connect via privy
* - sign a message
* @constructor
*/
export function EcdsaLogin() {
const { data: isLoggedIn } = useQuery(isCrossAppWalletLoggedInQuery);

if (isLoggedIn) {
return <DoEcdsaLogin />;
}
return <DoEcdsaAuthentication />;
}

function DoEcdsaLogin() {
const queryClient = useQueryClient();
const { mutate: logIn, isPending } = useMutation({
mutationKey: ["privy-cross-app", "connect"],
async mutationFn() {
await crossAppClient.requestConnection();
await queryClient.invalidateQueries({
queryKey: ["privy-cross-app"],
exact: false,
});
},
});
const { mutate: logIn } = usePrivyCrossAppAuthenticate();

return (
<Button type={"button"} onClick={() => logIn()} disabled={isPending}>
<Button type={"button"} onClick={() => logIn()}>
Connect via Privy
</Button>
);
}

function DoEcdsaAuthentication() {
const { mutate: authenticate, isPending } = useMutation({
mutationKey: ["privy-cross-app", "authenticate"],
async mutationFn() {
const wallet = crossAppClient.address;
if (!wallet) {
throw new Error("No wallet selected");
}

// Generate a random challenge
const challenge = generatePrivateKey();

// Build the message to sign
const message = `I want to connect to Frak and I accept the CGU.\n Verification code:${challenge}`;

// Launch the signature
const signature = (await crossAppClient.sendRequest(
"personal_sign",
[stringToHex(message), wallet]
)) as Hex | undefined;
if (!signature) {
console.warn("No signature");
throw new Error("No signature returned");
}

// Launch the backend authentication process
const { data, error } =
await authenticatedBackendApi.auth.wallet.ecdsaLogin.post({
expectedChallenge: challenge,
signature,
wallet: wallet as Address,
});
if (error) {
throw error;
}

// Extract a few data
const { token, sdkJwt, ...authentication } = data;
const session = { ...authentication, token };

// Store the session
jotaiStore.set(sessionAtom, session);
jotaiStore.set(sdkSessionAtom, sdkJwt);

// Track the event
trackEvent("cta-ecdsa-login");
},
});

return (
<>
<Button
type={"button"}
onClick={() => authenticate()}
disabled={isPending}
>
Authenticate
</Button>
</>
);
}
29 changes: 0 additions & 29 deletions packages/wallet/app/module/common/component/GlobalLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Header } from "@/module/common/component/Header";
import { useIsWebAuthNSupported } from "@/module/common/hook/useIsWebAuthNSupported";
import { cx } from "class-variance-authority";
import type { ReactNode } from "react";
import { Trans } from "react-i18next";
import { Navigation } from "../Navigation";
import styles from "./index.module.css";

Expand All @@ -15,15 +13,6 @@ export function GlobalLayout({
navigation?: boolean;
children: ReactNode;
}>) {
/**
* Check if webauthn is supported or not
*/
const isWebAuthnSupported = useIsWebAuthNSupported();

if (!isWebAuthnSupported) {
return <WebAuthNNotSupported />;
}

return (
<div className={"desktop scrollbars"}>
{header && <Header />}
Expand All @@ -40,21 +29,3 @@ export function GlobalLayout({
</div>
);
}

/**
* Small view telling the user that webauthn is not supported on his browser
* @constructor
*/
function WebAuthNNotSupported() {
return (
<div className={"desktop scrollbars"}>
<div className={styles.wrapper}>
<main className={styles.main}>
<div className={styles.inner}>
<Trans i18nKey="wallet.errors.webauthnNotSupported" />
</div>
</main>
</div>
</div>
);
}
86 changes: 86 additions & 0 deletions packages/wallet/app/module/common/hook/crossAppPrivyHooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import { crossAppClient } from "@/context/blockchain/privy-cross-client";
import { authenticatedBackendApi } from "@/context/common/backendClient";
import { sdkSessionAtom, sessionAtom } from "@/module/common/atoms/session";
import type { Session } from "@/types/Session";
import { jotaiStore } from "@module/atoms/store";
import { trackEvent } from "@module/utils/trackEvent";
import {
type UseMutationOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { type Address, type Hex, stringToHex } from "viem";
import { generatePrivateKey } from "viem/accounts";

export const crossAppWalletQuery = {
queryKey: ["privy-cross-app", "wallet"],
Expand All @@ -13,3 +25,77 @@ export const isCrossAppWalletLoggedInQuery = {
return crossAppClient.address;
},
} as const;

/**
* Function used to trigger a privy external authentication
*/
export function usePrivyCrossAppAuthenticate(
opts?: Omit<
UseMutationOptions<Session, Error>,
"mutationFn" | "mutationKey"
>
) {
const queryClient = useQueryClient();
return useMutation({
...opts,
mutationKey: ["privy-cross-app", "authenticate"],
async mutationFn() {
let wallet = crossAppClient.address;
if (!wallet) {
// If we don't have a wallet, request a connection
await crossAppClient.requestConnection();
await queryClient.invalidateQueries({
queryKey: ["privy-cross-app"],
exact: false,
});
wallet = crossAppClient.address;
}

// If we still don't have a wallet, throw an error
if (!wallet) {
throw new Error("No wallet selected");
}

// Generate a random challenge
const challenge = generatePrivateKey();

// Build the message to sign
const message = `I want to connect to Frak and I accept the CGU.\n Verification code:${challenge}`;

// Launch the signature
const signature = (await crossAppClient.sendRequest(
"personal_sign",
[stringToHex(message), wallet]
)) as Hex | undefined;
if (!signature) {
console.warn("No signature");
throw new Error("No signature returned");
}

// Launch the backend authentication process
const { data, error } =
await authenticatedBackendApi.auth.wallet.ecdsaLogin.post({
expectedChallenge: challenge,
signature,
wallet: wallet as Address,
});
if (error) {
throw error;
}

// Extract a few data
const { token, sdkJwt, ...authentication } = data;
const session = { ...authentication, token };

// Store the session
jotaiStore.set(sessionAtom, session);
jotaiStore.set(sdkSessionAtom, sdkJwt);

// Track the event
trackEvent("cta-ecdsa-login");

// Return the built session
return session;
},
});
}
57 changes: 51 additions & 6 deletions packages/wallet/app/module/listener/component/Login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
} from "@/module/authentication/hook/useGetOpenSsoLink";
import { useLogin } from "@/module/authentication/hook/useLogin";
import { sessionAtom } from "@/module/common/atoms/session";
import { RequireWebAuthN } from "@/module/common/component/RequireWebAuthN";
import { usePrivyCrossAppAuthenticate } from "@/module/common/hook/crossAppPrivyHooks";
import { useIsWebAuthNSupported } from "@/module/common/hook/useIsWebAuthNSupported";
import { modalDisplayedRequestAtom } from "@/module/listener/atoms/modalEvents";
import styles from "@/module/listener/component/Modal/index.module.css";
import type { LoginModalStepType, SsoMetadata } from "@frak-labs/core-sdk";
Expand Down Expand Up @@ -42,12 +43,19 @@ export function LoginModalStep({
const allowSso = params.allowSso ?? true;

const { login, isSuccess, isLoading, isError, error } = useLogin({
// Don't transmit the error up, to avoid modal closing
// On success, transmit the wallet address up a level
onSuccess: (session) => onFinish({ wallet: session.address }),
});
const { mutate: privyLogin, isPending: isPrivyLoading } =
usePrivyCrossAppAuthenticate({
// On success, transmit the wallet address up a level
onSuccess: (session) => onFinish({ wallet: session.address }),
});

const session = useAtomValue(sessionAtom);

const isWebAuthnSupported = useIsWebAuthNSupported();

/**
* Listen to the session status, and exit directly after a session is set in the storage
* - Will be triggered if the user goes through the external registration process
Expand All @@ -58,8 +66,32 @@ export function LoginModalStep({
}
}, [onFinish, session]);

/**
* If webauthn isn't supported, only show the ecdsa login button
*/
if (!isWebAuthnSupported) {
return (
<div
className={`${styles.modalListener__buttonsWrapper} ${prefixModalCss("buttons-wrapper")}`}
>
<div>
<button
type={"button"}
className={`${styles.modalListener__buttonSecondary} ${prefixModalCss("button-secondary")}`}
disabled={isPrivyLoading}
onClick={() => privyLogin()}
>
{isPrivyLoading && <Spinner />}
{metadata?.secondaryActionText ??
t("sdk.modal.login.default.privyAction")}
</button>
</div>
</div>
);
}

return (
<RequireWebAuthN>
<>
<div
className={`${styles.modalListener__buttonsWrapper} ${prefixModalCss("buttons-wrapper")}`}
>
Expand Down Expand Up @@ -90,6 +122,20 @@ export function LoginModalStep({
</button>
</div>
)}

<p></p>
<div>
<button
type={"button"}
className={`${styles.modalListener__buttonLink} ${prefixModalCss("button-privy")}`}
disabled={isPrivyLoading}
onClick={() => privyLogin()}
>
{isPrivyLoading && <Spinner />}
{t("sdk.modal.login.default.privyAction")}
</button>
</div>

<div>
<DismissButton />
</div>
Expand All @@ -106,7 +152,7 @@ export function LoginModalStep({
{error.message}
</p>
)}
</RequireWebAuthN>
</>
);
}

Expand Down Expand Up @@ -146,11 +192,10 @@ function SsoButton({
});

// Consume the pending sso if possible (maybe some hook to early exit here? Already working since we have the session listener)
const { data } = useConsumePendingSso({
useConsumePendingSso({
trackingId,
productId: context.productId,
});
console.log("useConsumePendingSso response", data);

// The text to display on the button
const text = useMemo<ReactNode>(
Expand Down
Loading

0 comments on commit bb2b823

Please sign in to comment.