Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

:feat add passport react context and hooks #2068

Open
wants to merge 51 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
bc72f13
add passport react context and hooks
shineli1984 Aug 5, 2024
736aabf
fix typing
shineli1984 Aug 5, 2024
d72ecd3
Merge branch 'main' into add-passport-react-context-and-hooks
shineli1984 Aug 5, 2024
8245bf6
exclude react in types
shineli1984 Aug 5, 2024
0b7fc6d
change hooks
shineli1984 Aug 6, 2024
df56bbf
use context
shineli1984 Aug 6, 2024
a9b60d0
change hook interfaces
shineli1984 Aug 7, 2024
a418e26
add code snippets in example
shineli1984 Aug 7, 2024
2d97c60
add typeAssertions back
shineli1984 Aug 7, 2024
ea500f3
lint example
shineli1984 Aug 7, 2024
84cfbc1
lint
shineli1984 Aug 7, 2024
6442f79
Merge branch 'main' into add-passport-react-context-and-hooks
shineli1984 Aug 8, 2024
7239164
remove doc mk for now
shineli1984 Aug 8, 2024
81010a6
add error to context
shineli1984 Aug 8, 2024
57e3c50
example lint
shineli1984 Aug 8, 2024
de02f09
return provider when login with web3 provider
shineli1984 Aug 8, 2024
5cc9dc6
add accounts context
shineli1984 Aug 8, 2024
2e8cf9b
export useAccount
shineli1984 Aug 8, 2024
f1274ba
address comments
shineli1984 Aug 8, 2024
bcd17fb
merge main
shineli1984 Aug 8, 2024
b5f63bc
add to the consolidated example
shineli1984 Aug 8, 2024
c42f2dd
yarn lock
shineli1984 Aug 8, 2024
49b8f9a
rename PassportProvider
shineli1984 Aug 9, 2024
08634f2
change provider name
shineli1984 Aug 9, 2024
b5980b6
set error when idtoken is null
shineli1984 Aug 9, 2024
05bf6a5
change provider interface
shineli1984 Aug 12, 2024
85ed2fe
add metrics
shineli1984 Aug 12, 2024
55afc6b
typing
shineli1984 Aug 12, 2024
a1542bd
fix types
shineli1984 Aug 12, 2024
aaefc91
fix example
shineli1984 Aug 12, 2024
aa47ad0
add tests
shineli1984 Aug 13, 2024
8ae1029
yarn
shineli1984 Aug 13, 2024
4bf86e1
add missing types
shineli1984 Aug 13, 2024
2fc813e
improve interface
shineli1984 Aug 13, 2024
7a3f0bf
yarn
shineli1984 Aug 14, 2024
b7d419b
fix example
shineli1984 Aug 14, 2024
dbac851
separate to different context
shineli1984 Aug 14, 2024
efd65ed
yarn
shineli1984 Aug 14, 2024
cc0450f
yarn lock
shineli1984 Aug 14, 2024
0525454
change interface
shineli1984 Aug 15, 2024
973c147
yarn lock
shineli1984 Aug 15, 2024
c779ca9
fix snippet
shineli1984 Aug 15, 2024
b9b94e1
fix snippets
shineli1984 Aug 15, 2024
36f7c96
fix import
shineli1984 Aug 15, 2024
02cbcea
move tokens out of hooks
shineli1984 Aug 16, 2024
4383ba8
use events from passport
shineli1984 Aug 16, 2024
d2e9e3e
Revert "use events from passport"
shineli1984 Aug 19, 2024
74bb0f5
revert the event emitter
shineli1984 Aug 19, 2024
8a55532
resolve conflicts
shineli1984 Aug 19, 2024
c708c61
add mapper
shineli1984 Aug 19, 2024
c984dfb
revert back to using passport methods
shineli1984 Aug 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export default function Logout() {
>
Return to examples
</a>
<b> OR </b>
<a
className="underline"
href="/use-context"
>
Return to examples using
{' '}
<pre>passport.PassportProvider</pre>
</a>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client';

import { PassportProvider } from '@/context/passport2';
import { ReactNode } from 'react';

export default function Layout({
children,
}: Readonly<{
children: ReactNode;
}>) {
return (
<PassportProvider>{children}</PassportProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use client';

import { passport } from '@imtbl/sdk';

export default function Page() {
const {
login, loginWithEthersjs, loginWithoutWallet, logout,
} = passport.usePassport();
const { idToken } = passport.useIdToken();
const { accessToken } = passport.useAccessToken();
const { linkedAddresses } = passport.useLinkedAddresses();
const { userInfo } = passport.useUserInfo();

return (
<div className="flex flex-col items-center justify-center min-h-screen p-8">
<h1 className="text-3xl font-bold mb-8">Passport Identity Examples</h1>
<div className="grid grid-cols-1 gap-4 text-center">
<div>
<p className="mb-2">
<b>Access Token:</b>
{' '}
{accessToken}
</p>
<p className="mb-2">
<b>ID Token:</b>
{' '}
{idToken}
</p>
<p className="mb-2">
<b>Linked Addresses:</b>
{' '}
{linkedAddresses}
</p>
<p className="mb-2">
<b>User Info:</b>
{' '}
{JSON.stringify(userInfo, null, 2)}
</p>
</div>
<button
className="bg-black text-white py-2 px-4 rounded hover:bg-gray-800"
onClick={login}
type="button"
>
Login
</button>
<button
className="bg-black text-white py-2 px-4 rounded hover:bg-gray-800"
onClick={loginWithoutWallet}
type="button"
>
Login Without Wallet
</button>
<button
className="bg-black text-white py-2 px-4 rounded hover:bg-gray-800"
onClick={loginWithEthersjs}
type="button"
>
Login With EtherJS
</button>
<button
className="bg-black text-white py-2 px-4 rounded hover:bg-gray-800"
onClick={logout}
type="button"
>
Logout
</button>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { config, passport } from '@imtbl/sdk';
import { ReactNode } from 'react';

export function PassportProvider({ children }: { children: ReactNode }) {
return (
<passport.PassportProvider
config={{
baseConfig: {
environment: config.Environment.SANDBOX, // or config.Environment.SANDBOX
publishableKey: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY || '<YOUR_PUBLISHABLE_KEY>', // replace with your publishable API key from Hub
},
clientId: process.env.NEXT_PUBLIC_CLIENT_ID || '<YOUR_CLIENT_ID>', // replace with your client ID from Hub
redirectUri: 'http://localhost:3000/redirect', // replace with one of your redirect URIs from Hub
logoutRedirectUri: 'http://localhost:3000/logout', // replace with one of your logout URIs from Hub
audience: 'platform_api',
scope: 'openid offline_access email transact',
}}
>
{children}
</passport.PassportProvider>
);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,4 @@
"tests/**"
]
}
}
}
1 change: 1 addition & 0 deletions packages/passport/sdk/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"tsconfigRootDir": "."
},
"rules": {
"react/react-in-jsx-scope": ["off"],
"@typescript-eslint/naming-convention": [
"error",
{
Expand Down
10 changes: 10 additions & 0 deletions packages/passport/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,13 @@ export {
DeviceConnectResponse,
DeviceTokenResponse,
} from './types';

export {
PassportProvider,
useAccessToken,
useIdToken,
useLinkedAddresses,
usePassport,
useUserInfo,
useAccounts,
} from './react/PassportContext';
242 changes: 242 additions & 0 deletions packages/passport/sdk/src/react/PassportContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/* eslint-disable @typescript-eslint/naming-convention */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's quite a bit of functionality within this module - can we please add some tests?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll need to address all the feedback first.

import React, {
createContext, useContext, useEffect, useMemo,
useState,
} from 'react';
import { Web3Provider } from '@ethersproject/providers';
import { Passport } from '../Passport';
import { PassportModuleConfiguration, UserProfile } from '../types';

type PassportContextType = {
passportInstance: Passport;
isLoggedIn: boolean;
login: () => Promise<string[]>;
logout: () => Promise<void>;
loginWithoutWallet: () => Promise<UserProfile | null>;
loginWithEthersjs: () => Promise<{ accounts: string[], provider: Web3Provider | null }>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would provider be null here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll move provider onto the context but it's null when provider.send('eth_requestAccounts', []) rejects.

isLoading: boolean;
error: Error | null;
accounts: string[];
};

const PassportContext = createContext<PassportContextType>({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, its less finicky if you do

const PassportContext = createContext<PassportContextType|null>(null);

Works just as well as far as I know.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to consider the loginCallback too?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah. I can add that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

passportInstance: {} as Passport,
isLoggedIn: false,
login: () => { throw new Error('login must be used within a PassportProvider'); },
logout: () => { throw new Error('logout must be used within a PassportProvider'); },
loginWithoutWallet: () => { throw new Error('loginWithoutWallet must be used within a PassportProvider'); },
loginWithEthersjs: () => { throw new Error('loginWithEthersjs must be used within a PassportProvider'); },
isLoading: false,
error: null,
accounts: [],
});

type PassportProviderProps = {
config: PassportModuleConfiguration;
children: React.ReactNode;
};

export function PassportProvider({ children, config }: PassportProviderProps) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend calling this something like ReactProvider to avoid confusion with the provider on line 17

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering the usage side, maybe PassportReactProvider?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally you're going to be doing

import { passport } from '@imtbl/sdk';

so the double "passport" in

passport.PassportReactProvider

would be unnecessary IMO

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be useful if we could pass in the passport instance, rather than the config.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reason: when using checkout, you want to be able to pass the passport instance to checkout. Obviously you can still get it by calling usePassport but it makes it more tricky as you would want to initialise checkout and passport together

const [isLoggedIn, setLoggedIn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [accounts, setAccounts] = useState<string[]>([]);
const [error, setError] = useState<Error | null>(null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm no... I don't think you want to say this is Error or null when you're putting any in to it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

casted to Error in catch statements

const passportInstance = new Passport(config);
const v = useMemo(() => ({
passportInstance,
isLoggedIn,
isLoading,
accounts,
error,
login: async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why couple the login method to EVM? Any reason why we can't follow the existing interface?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going with our documentation to separate

I assume we want user to use the first one? what are you suggesting? name this function as connectEVM and the other one as login?

try {
setError(null);
setIsLoading(true);
const provider = passportInstance.connectEvm();
const acc = await provider.request({ method: 'eth_requestAccounts' });
setLoggedIn(true);
setAccounts(acc);
return accounts;
} catch (e: any) {
setError(e);
} finally {
setIsLoading(false);
}
return [];
},
logout: async () => {
try {
setError(null);
setIsLoading(true);
await passportInstance.logout();
setLoggedIn(false);
setAccounts([]);
} catch (e: any) {
setError(e);
} finally {
setIsLoading(false);
}
},
loginWithoutWallet: async () => {
try {
setError(null);
setIsLoading(true);
const profile = await passportInstance.login();
setLoggedIn(true);
return profile;
} catch (e: any) {
setError(e);
return null;
} finally {
setIsLoading(false);
}
},
loginWithEthersjs: async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't ethers be treated as a peer dependency? Isn't it safe to assume that partners intending to use ethers will already have it installed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passport sdk package already has @ethersproject/providers as an dependency tho. Should I change it to peer?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I know that library is used internally only whereas you're exposing it here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use some of the modules from Ethers internally, but we do not expose Ethers to consumers of Passport. By exposing Ethers from Passport like this, we'll need to continue to support it indefinitely, or make a breaking change if we decide to remove it.

This could also open up the door for partners to request that we add methods for other wallet connection libraries (e.g. Viem), so I think it's best if we just expose the raw Passport provider and let consumers wrap it themselves.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try remove this and see what kind of experience it is with 3rd party provider.

try {
setError(null);
setIsLoading(true);
// eslint-disable-next-line new-cap
const provider = new Web3Provider(passportInstance.connectEvm());
const acc = await provider.send('eth_requestAccounts', []);
setLoggedIn(true);
return { accounts: acc, provider };
} catch (e: any) {
setError(e);
return { accounts: [], provider: null };
} finally {
setIsLoading(false);
}
},
}), [config, isLoggedIn]);

return (
<PassportContext.Provider value={v}>
{children}
</PassportContext.Provider>
);
}

export function usePassport() {
const c = useContext(PassportContext);
if (!c) {
throw new Error('usePassport must be used within a PassportProvider');
}
return c;
}

export function useIdToken() {
const { passportInstance, isLoading, isLoggedIn } = usePassport();
const [idToken, setIdToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

const getIdToken = () => {
if (isLoggedIn) {
setLoading(true);
passportInstance
.getIdToken()
.then((t) => setIdToken(t || null))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'd want to handle t being undefined here in some way. What would cause this? And what would we expect to get back from useIdToken in this scenario?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is from calling oidcClient on this line:

const oidcUser = await this.userManager.getUser();

So we don't really have control over.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm but in what scenario would this happen? If isLoggedIn is true and getIdToken returns undefined, should that set an error for example? Consider the result of useIdToken - it would be { idToken: null, error: null, loading: false } - not helpful

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be equivalent to an internal error / bug scenario. I can set the error in this case. however, it complex things a bit more because now we need to consider if we need to clear up all the other fields.

.catch((e) => setError(e))
.finally(() => setLoading(false));
} else {
setIdToken(null);
}

return () => {
setIdToken(null);
};
};
useEffect(getIdToken, [passportInstance, isLoggedIn]);
return {
idToken, isLoading: isLoading || loading, error, refetch: getIdToken,
};
}

export function useAccessToken() {
const { passportInstance, isLoading, isLoggedIn } = usePassport();
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

const getAccessToken = () => {
if (isLoggedIn) {
setLoading(true);
passportInstance
.getAccessToken()
.then((t) => setAccessToken(t || null))
.catch((e) => setError(e))
.finally(() => setLoading(false));
} else {
setAccessToken(null);
}

return () => {
setAccessToken(null);
};
};
useEffect(getAccessToken, [passportInstance, isLoggedIn]);
return {
accessToken, isLoading: isLoading || loading, error, refetch: getAccessToken,
};
}

export function useLinkedAddresses() {
const { passportInstance, isLoading, isLoggedIn } = usePassport();
const [linkedAddresses, setLinkedAddresses] = useState<string[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

const getLinkedAddresses = () => {
if (isLoggedIn) {
setLoading(true);
passportInstance
.getLinkedAddresses()
.then((t) => setLinkedAddresses(t || null))
.catch((e) => setError(e))
.finally(() => setLoading(false));
} else {
setLinkedAddresses(null);
}

return () => {
setLinkedAddresses(null);
};
};
useEffect(getLinkedAddresses, [passportInstance, isLoggedIn]);
return {
linkedAddresses, isLoading: isLoading || loading, error, refetch: getLinkedAddresses,
};
}

export function useUserInfo() {
const { passportInstance, isLoading, isLoggedIn } = usePassport();
const [userInfo, setUserInfo] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

const getUserInfo = () => {
if (isLoggedIn) {
setLoading(true);
passportInstance
.getUserInfo()
.then((t) => setUserInfo(t || null))
.catch((e) => setError(e))
.finally(() => setLoading(false));
} else {
setUserInfo(null);
}

return () => {
setUserInfo(null);
};
};
useEffect(getUserInfo, [passportInstance, isLoggedIn]);
return {
userInfo, isLoading: isLoading || loading, error, refetch: getUserInfo,
};
}

export function useAccounts() {
const { accounts, isLoading, error } = usePassport();
return { accounts, isLoading, error };
}
Loading
Loading