diff --git a/package.json b/package.json index 8f1e10098..37bc88c37 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "build": "turbo build", "dev": "turbo watch build", - "clean": "rimraf packages/*/dist", + "clean": "rimraf .turbo packages/*/dist", "lint": "biome check", "lint:fix": "biome check --write", "new:package": "NODE_OPTIONS='--import tsx' plop --plopfile=plopfile.ts", diff --git a/packages/client/src/AuthenticatedUser.ts b/packages/client/src/AuthenticatedUser.ts index 3f0f0c85c..c78ebbeb7 100644 --- a/packages/client/src/AuthenticatedUser.ts +++ b/packages/client/src/AuthenticatedUser.ts @@ -9,7 +9,7 @@ const SPONSORED_CLAIM = 'tag:lens.dev,2024:sponsored'; export type AuthenticatedUser = { address: EvmAddress; app: EvmAddress; - authentication_id: UUID; + authenticationId: UUID; role: Role; signer: EvmAddress; sponsored: boolean; @@ -26,7 +26,7 @@ export function authenticatedUser( return ok({ address: claims.act?.sub ?? never('Account Manager must have an Actor Claim'), app: claims.aud, - authentication_id: claims.sid, + authenticationId: claims.sid, role: Role.AccountManager, signer: claims.sub, sponsored: claims[SPONSORED_CLAIM], @@ -36,7 +36,7 @@ export function authenticatedUser( return ok({ address: claims.act?.sub ?? never('Account Owner must have an Actor Claim'), app: claims.aud, - authentication_id: claims.sid, + authenticationId: claims.sid, role: Role.AccountOwner, signer: claims.sub, sponsored: claims[SPONSORED_CLAIM], @@ -46,7 +46,7 @@ export function authenticatedUser( return ok({ address: claims.sub, app: claims.aud, - authentication_id: claims.sid, + authenticationId: claims.sid, role: Role.OnboardingUser, signer: claims.sub, sponsored: claims[SPONSORED_CLAIM], @@ -56,7 +56,7 @@ export function authenticatedUser( return ok({ address: claims.sub, app: claims.aud, - authentication_id: claims.sid, + authenticationId: claims.sid, role: Role.Builder, signer: claims.sub, sponsored: claims[SPONSORED_CLAIM], diff --git a/packages/client/src/clients.test.ts b/packages/client/src/clients.test.ts index b04579ccc..445e359c8 100644 --- a/packages/client/src/clients.test.ts +++ b/packages/client/src/clients.test.ts @@ -50,7 +50,7 @@ describe(`Given an instance of the ${PublicClient.name}`, () => { }); }); - describe('When authenticating via the `login` convenience method', () => { + describe(`When authenticating via the '${PublicClient.prototype.login.name}' convenience method`, () => { it('Then it should return an Err with any error thrown by the provided `SignMessage` function', async () => { const authenticated = await client.login({ accountOwner: { @@ -108,6 +108,25 @@ describe(`Given an instance of the ${PublicClient.name}`, () => { }); describe('And a SessionClient created from it', () => { + describe(`When invoking the 'logout' method`, () => { + it('Then it should revoke the current authenticated session and clear the credentials from the storage', async () => { + const authenticated = await client.login({ + accountOwner: { + account, + owner: signer, + app, + }, + signMessage: signMessageWith(wallet), + }); + assertOk(authenticated); + + const result = await authenticated.value.logout(); + assertOk(result); + assertErr(await currentSession(authenticated.value)); + assertErr(await authenticated.value.getAuthenticatedUser()); + }); + }); + describe('When a request fails with UNAUTHENTICATED extension code', () => { const server = setupServer( graphql.query( diff --git a/packages/client/src/clients.ts b/packages/client/src/clients.ts index 07059b249..16c6edd08 100644 --- a/packages/client/src/clients.ts +++ b/packages/client/src/clients.ts @@ -30,7 +30,7 @@ import { type Logger, getLogger } from 'loglevel'; import type { SwitchAccountRequest } from '@lens-protocol/graphql'; import { type AuthConfig, authExchange } from '@urql/exchange-auth'; import { type AuthenticatedUser, authenticatedUser } from './AuthenticatedUser'; -import { switchAccount, transactionStatus } from './actions'; +import { revokeAuthentication, switchAccount, transactionStatus } from './actions'; import type { ClientConfig } from './config'; import { type Context, configureContext } from './context'; import { @@ -331,6 +331,17 @@ class SessionClient extends AbstractClient< }); } + /** + * Log out the current session. + */ + logout(): ResultAsync { + return this.getAuthenticatedUser() + .andThen(({ authenticationId }) => revokeAuthentication(this, { authenticationId })) + .andTee(() => + ResultAsync.fromPromise(this.credentials.reset(), (err) => UnexpectedError.from(err)), + ); + } + /** * {@inheritDoc AbstractClient.isPublicClient} */ diff --git a/packages/react/package.json b/packages/react/package.json index c87e114b2..abe7f6633 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -17,14 +17,14 @@ "require": "./dist/index.cjs" }, "./ethers": { - "types": "./dist/ethers.d.cts", "import": "./dist/ethers.js", - "require": "./dist/ethers.cjs" + "require": "./dist/ethers.cjs", + "types": "./dist/ethers.d.cts" }, "./viem": { - "types": "./dist/viem.d.cts", "import": "./dist/viem.js", - "require": "./dist/viem.cjs" + "require": "./dist/viem.cjs", + "types": "./dist/viem.d.cts" } }, "typesVersions": { diff --git a/packages/react/src/LensProvider.tsx b/packages/react/src/LensProvider.tsx index 0b2b1b2ae..e965798fd 100644 --- a/packages/react/src/LensProvider.tsx +++ b/packages/react/src/LensProvider.tsx @@ -1,7 +1,6 @@ import type { PublicClient } from '@lens-protocol/client'; import React from 'react'; import type { ReactNode } from 'react'; -import { Provider as UrqlProvider } from 'urql'; import { LensContextProvider } from './context'; @@ -39,9 +38,5 @@ export type LensProviderProps = { * ``` */ export function LensProvider({ children, client }: LensProviderProps) { - return ( - - {children} - - ); + return {children}; } diff --git a/packages/react/src/account/index.ts b/packages/react/src/account/index.ts new file mode 100644 index 000000000..5366ad49a --- /dev/null +++ b/packages/react/src/account/index.ts @@ -0,0 +1 @@ +export * from './useAccount'; diff --git a/packages/react/src/account/useAccount.ts b/packages/react/src/account/useAccount.ts new file mode 100644 index 000000000..1b9fa3a12 --- /dev/null +++ b/packages/react/src/account/useAccount.ts @@ -0,0 +1,41 @@ +import { type Account, AccountQuery, type AccountRequest } from '@lens-protocol/graphql'; +import { + type ReadResult, + type Suspendable, + type SuspendableResult, + type SuspenseResult, + useSuspendableQuery, +} from '../helpers'; + +export type UseAccountArgs = AccountRequest; + +/** + * Fetch a single Account. + * + * This signature supports React Suspense: + * + * ```tsx + * const { data } = useAccount({ managedBy: evmAddress('0x…'), suspense: true }); + * ``` + */ +export function useAccount(args: UseAccountArgs & Suspendable): SuspenseResult; + +/** + * Fetch a single Account. + * + * ```tsx + * const { data } = useAccount({ managedBy: evmAddress('0x…') }); + * ``` + */ +export function useAccount(args: UseAccountArgs): ReadResult; + +export function useAccount({ + suspense = false, + ...request +}: UseAccountArgs & { suspense?: boolean }): SuspendableResult { + return useSuspendableQuery({ + document: AccountQuery, + variables: { request }, + suspense, + }); +} diff --git a/packages/react/src/authentication/index.ts b/packages/react/src/authentication/index.ts index 940837ed6..94a2a75eb 100644 --- a/packages/react/src/authentication/index.ts +++ b/packages/react/src/authentication/index.ts @@ -1,4 +1,5 @@ export * from './useAccountsAvailable'; export * from './useAuthenticatedUser'; export * from './useLogin'; +export * from './useLogout'; export * from './useSessionClient'; diff --git a/packages/react/src/authentication/useAccountsAvailable.ts b/packages/react/src/authentication/useAccountsAvailable.ts index f9517b765..1828b53b8 100644 --- a/packages/react/src/authentication/useAccountsAvailable.ts +++ b/packages/react/src/authentication/useAccountsAvailable.ts @@ -1,12 +1,52 @@ -import { AccountsAvailableQuery, type AccountsAvailableRequest } from '@lens-protocol/graphql'; -import { useQuery } from 'urql'; +import { + type AccountAvailable, + AccountsAvailableQuery, + type AccountsAvailableRequest, + type Paginated, +} from '@lens-protocol/graphql'; +import { + type ReadResult, + type Suspendable, + type SuspendableResult, + type SuspenseResult, + useSuspendableQuery, +} from '../helpers'; + +export type UseAccountsAvailableArgs = AccountsAvailableRequest; + +/** + * Fetch the accounts available for a given address. + * + * This signature supports React Suspense: + * + * ```tsx + * const { data } = useAccountsAvailable({ managedBy: evmAddress('0x…'), suspense: true }); + * ``` + */ +export function useAccountsAvailable( + args: UseAccountsAvailableArgs & Suspendable, +): SuspenseResult>; /** * Fetch the accounts available for a given address. + * + * ```tsx + * const { data } = useAccountsAvailable({ managedBy: evmAddress('0x…') }); + * ``` */ -export function useAccountsAvailable(request: AccountsAvailableRequest) { - return useQuery({ - query: AccountsAvailableQuery, +export function useAccountsAvailable( + args: UseAccountsAvailableArgs, +): ReadResult>; + +export function useAccountsAvailable({ + suspense = false, + ...request +}: UseAccountsAvailableArgs & { suspense?: boolean }): SuspendableResult< + Paginated +> { + return useSuspendableQuery({ + document: AccountsAvailableQuery, variables: { request }, + suspense: suspense, }); } diff --git a/packages/react/src/authentication/useAuthenticatedUser.test.ts b/packages/react/src/authentication/useAuthenticatedUser.test.ts index abc738129..a6d8d67b4 100644 --- a/packages/react/src/authentication/useAuthenticatedUser.test.ts +++ b/packages/react/src/authentication/useAuthenticatedUser.test.ts @@ -1,8 +1,9 @@ -import { type SessionClient, never } from '@lens-protocol/client'; +import type { SessionClient } from '@lens-protocol/client'; import type { AuthenticatedUser } from '@lens-protocol/client'; import { account, app, createPublicClient, signer, wallet } from '@lens-protocol/client/test-utils'; import { signMessageWith } from '@lens-protocol/client/viem'; import { beforeAll, describe, expect, it, vi } from 'vitest'; + import { renderHookWithContext } from '../test-utils'; import { useAuthenticatedUser } from './useAuthenticatedUser'; diff --git a/packages/react/src/authentication/useLogout.ts b/packages/react/src/authentication/useLogout.ts new file mode 100644 index 000000000..5db50fd4e --- /dev/null +++ b/packages/react/src/authentication/useLogout.ts @@ -0,0 +1,27 @@ +import type { UnauthenticatedError, UnexpectedError } from '@lens-protocol/client'; +import { invariant } from '@lens-protocol/types'; + +import { type UseAsyncTask, useAsyncTask } from '../helpers'; +import { useSessionClient } from './useSessionClient'; + +export type LogoutError = UnauthenticatedError | UnexpectedError; + +/** + * Log out of Lens. + * + * ```tsx + * const { execute, error } = useLogout(); + * ``` + */ +export function useLogout(): UseAsyncTask { + const { data: sessionClient } = useSessionClient(); + + return useAsyncTask(() => { + invariant( + sessionClient, + 'It appears that you are not logged in. Please log in before attempting to log out.', + ); + + return sessionClient.logout(); + }); +} diff --git a/packages/react/src/context.tsx b/packages/react/src/context.tsx index 46e272d2b..886ca8189 100644 --- a/packages/react/src/context.tsx +++ b/packages/react/src/context.tsx @@ -2,6 +2,8 @@ import type { PublicClient, SessionClient } from '@lens-protocol/client'; import type { AuthenticatedUser } from '@lens-protocol/client'; import { invariant } from '@lens-protocol/types'; import React, { type ReactNode, useContext, useEffect, useState } from 'react'; +import { Provider as UrqlProvider } from 'urql'; + import { ReadResult, type SuspenseResult } from './helpers'; /** @@ -94,7 +96,11 @@ type LensContextProviderProps = { export function LensContextProvider({ children, client }: LensContextProviderProps) { const value = useLensContextValue(client); - return {children}; + return ( + + {children} + + ); } /** @@ -105,7 +111,7 @@ export function useLensContext(): LensContextValue { invariant( context, - 'Could not find Lens SDK context, ensure your code is wrapped in a Lens ', + 'Could not find Lens SDK context, ensure your code is wrapped in a ', ); return context; diff --git a/packages/react/src/helpers/index.ts b/packages/react/src/helpers/index.ts index 17cce551a..af5d98c06 100644 --- a/packages/react/src/helpers/index.ts +++ b/packages/react/src/helpers/index.ts @@ -1,51 +1,3 @@ +export * from './reads'; +export * from './results'; export * from './tasks'; - -/** - * A read hook result. - * - * It's a discriminated union of the possible results of a read operation: - * - Rely on the `loading` value to determine if the `data` or `error` can be evaluated. - * - If `error` is `undefined`, then `data` value will be available. - */ -export type ReadResult = - | { - data: undefined; - error: undefined; - loading: true; - } - | { - data: T; - error: undefined; - loading: false; - } - | { - data: undefined; - error: E; - loading: false; - }; - -/** - * @internal - */ -export const ReadResult = { - Initial: (): ReadResult => ({ - data: undefined, - error: undefined, - loading: true, - }), - Success: (data: T): ReadResult => ({ - data, - error: undefined, - loading: false, - }), - Failure: (error: E): ReadResult => ({ - data: undefined, - error, - loading: false, - }), -}; - -/** - * A read hook result that supports React Suspense - */ -export type SuspenseResult = { data: T }; diff --git a/packages/react/src/helpers/reads.ts b/packages/react/src/helpers/reads.ts new file mode 100644 index 000000000..0ff60c06d --- /dev/null +++ b/packages/react/src/helpers/reads.ts @@ -0,0 +1,46 @@ +import type { AnyVariables, StandardData } from '@lens-protocol/graphql'; +import { type TypedDocumentNode, useQuery } from 'urql'; + +import { never } from '@lens-protocol/types'; +import { useMemo } from 'react'; +import { ReadResult, type SuspendableResult } from './results'; + +/** + * @internal + */ +export type Suspendable = { suspense: true }; + +/** + * @internal + */ +export type UseSuspendableQueryArgs = { + document: TypedDocumentNode, Variables>; + variables: Variables; + suspense: boolean; +}; + +/** + * @internal + */ +export function useSuspendableQuery({ + document, + variables, + suspense, +}: UseSuspendableQueryArgs): SuspendableResult { + const [{ data, fetching, error }] = useQuery({ + query: document, + variables, + context: useMemo(() => ({ suspense }), [suspense]), + }); + + if (fetching) { + return ReadResult.Initial(); + } + + if (error) { + // biome-ignore lint/suspicious/noExplicitAny: temporary workaround + return ReadResult.Failure(error) as any; + } + + return ReadResult.Success(data?.value ?? never('No data returned')); +} diff --git a/packages/react/src/helpers/results.ts b/packages/react/src/helpers/results.ts new file mode 100644 index 000000000..9d4983141 --- /dev/null +++ b/packages/react/src/helpers/results.ts @@ -0,0 +1,51 @@ +/** + * A read hook result. + * + * It's a discriminated union of the possible results of a read operation: + * - Rely on the `loading` value to determine if the `data` or `error` can be evaluated. + * - If `error` is `undefined`, then `data` value will be available. + */ +export type ReadResult = + | { + data: undefined; + error: undefined; + loading: true; + } + | { + data: T; + error: undefined; + loading: false; + } + | { + data: undefined; + error: E; + loading: false; + }; + +/** + * @internal + */ +export const ReadResult = { + Initial: (): ReadResult => ({ + data: undefined, + error: undefined, + loading: true, + }), + Success: (data: T): ReadResult => ({ + data, + error: undefined, + loading: false, + }), + Failure: (error: E): ReadResult => ({ + data: undefined, + error, + loading: false, + }), +}; + +/** + * A read hook result that supports React Suspense + */ +export type SuspenseResult = { data: T }; + +export type SuspendableResult = ReadResult | SuspenseResult; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b14e7f3bf..4b9a6bbe6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,5 +1,6 @@ export * from '@lens-protocol/client'; +export * from './account'; export * from './authentication'; export type { AsyncTaskIdle,