diff --git a/README.md b/README.md index 0a6ef9ce..ce547036 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ root.render( ); ``` -## ⚙️ The hook `useDirectus` +## ⚙️ Hooks + +### `useDirectus` After adding the provider, you can access the configured client anywhere in the app, using the `useDirectus` hook: @@ -93,6 +95,43 @@ export const TodoList = () => { }; ``` +### `useDirectusAuth` + +The `useDirectusAuth` hook provides a few methods for working with the [Directus Authentication API](https://docs.directus.io/reference/old-sdk.html#authentication): + +- `login` - a function that accepts an email and password and returns a promise that resolves to the user object if the login is successful or rejects with an error otherwise +- `logout` - a function that logs out the current user +- `user` - the current user object +- `authState` - the current authentication state, one of `loading` (the initial state), `logged-in` or `logged-out` + +```jsx +import { useDirectusAuth } from 'react-directus'; + +const Login = () => { + const { login } = useDirectusAuth(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const { email, password } = e.currentTarget.elements; + login(email.value, password.value).catch(err => { + console.error(err); + }); + }; + + return ( +
+ + + +
+ ); +}; + +export default Login; + +``` + ## 🧩 Components (so far...) This package contains a few components for working with Direcuts [files](https://docs.directus.io/reference/files/). They are all configured for using the `apiUrl` specified in the provider. Hopefully, more will come in the future 🤗. diff --git a/jest.config.json b/jest.config.json index 84047850..26f2d16c 100644 --- a/jest.config.json +++ b/jest.config.json @@ -5,6 +5,7 @@ "testMatch": ["/src/**/*(*.)@(spec|test).[tj]s?(x)"], "moduleNameMapper": { "^@/(.*)$": "/src/$1", - "^@components/(.*)$": "/src/components/$1" + "^@components/(.*)$": "/src/components/$1", + "^@hooks/(.*)$": "/src/hooks/$1" } } diff --git a/src/DirectusProvider.tsx b/src/DirectusProvider.tsx index 92231cd8..a0384c87 100644 --- a/src/DirectusProvider.tsx +++ b/src/DirectusProvider.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { Directus, TypeMap } from '@directus/sdk'; import { + AuthStates, DirectusAssetProps, DirectusContextType, DirectusContextTypeGeneric, @@ -9,6 +9,8 @@ import { DirectusProviderProps, } from '@/types'; +import { Directus, TypeMap, UserType } from '@directus/sdk'; + import { DirectusAsset } from '@components/DirectusAsset'; import { DirectusImage } from '@components/DirectusImage'; @@ -22,12 +24,18 @@ export const DirectusContext = React.createContext({ apiUrl, options, + autoLogin, children, }: DirectusProviderProps): JSX.Element => { + const [user, setUser] = React.useState(null); + const [authState, setAuthState] = React.useState('loading'); + + const directus = React.useMemo(() => new Directus(apiUrl, options), [apiUrl, options]); + const value = React.useMemo>( () => ({ - apiUrl: apiUrl, - directus: new Directus(apiUrl, options), + apiUrl, + directus, DirectusAsset: ({ asset, render, ...props }: DirectusAssetProps) => { console.warn('Deprecated: Please import DirectusAsset directly from react-directus'); return ; @@ -36,10 +44,46 @@ export const DirectusProvider = ({ console.warn('Deprecated: Please import DirectusImage directly from react-directus'); return ; }, + _directusUser: user, + _setDirecctusUser: setUser, + _authState: authState, + _setAuthState: setAuthState, }), - [apiUrl, options] + [apiUrl, directus, user, authState] ); + React.useEffect(() => { + const checkAuth = async () => { + let newAuthState: AuthStates = 'unauthenticated'; + try { + await directus.auth.refresh(); + const token = await directus.auth.token; + + if (token) { + const dUser = (await directus.users.me.read({ + // * is a valid field, but typescript doesn't like it + // It's a wildcard, so it will return all fields + // This is the only way to get all fields + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fields: ['*'] as any, + })) as UserType; + + if (dUser) { + newAuthState = 'authenticated'; + setUser(dUser); + } + } + } catch (error) { + console.log('auth-error', error); + } finally { + setAuthState(newAuthState || 'unauthenticated'); + } + }; + if (autoLogin) { + checkAuth(); + } + }, [directus, autoLogin]); + return {children}; }; diff --git a/src/hooks/useDirectusAuth.tsx b/src/hooks/useDirectusAuth.tsx new file mode 100644 index 00000000..09b3a023 --- /dev/null +++ b/src/hooks/useDirectusAuth.tsx @@ -0,0 +1,95 @@ +import { DirectusAuthHook } from '../types'; +import { DirectusContext } from '../DirectusProvider'; +import React from 'react'; +import { UserType } from '@directus/sdk'; + +/** + * A hook to access the Directus authentication state and methods. + * + * @example + * ```tsx + * import { useDirectusAuth } from 'react-directus'; + * + * const Login = () => { + * const { login } = useDirectusAuth(); + * + * const handleSubmit = (e: React.FormEvent) => { + * e.preventDefault(); + * + * const { email, password } = e.currentTarget.elements; + * login(email.value, password.value) + * .catch((err) => { + * console.error(err); + * }); + * }; + * + * return ( + *
+ * + * + * + *
+ * ); + * }; + * + * export default Login; + * ``` + */ +export const useDirectusAuth = (): DirectusAuthHook => { + const directusContext = React.useContext(DirectusContext); + + if (!directusContext) { + throw new Error('useDirectusAuth has to be used within the DirectusProvider'); + } + + const { + directus, + _authState: authState, + _setAuthState: setAuthState, + _directusUser: directusUser, + _setDirecctusUser: setDirectusUser, + } = directusContext; + + const login = React.useCallback( + async (email: string, password: string) => { + await directus.auth.login({ + email, + password, + }); + + const dUser = (await directus.users.me.read({ + fields: ['*'], + })) as UserType; + + if (dUser) { + setDirectusUser(dUser); + setAuthState('authenticated'); + } else { + setDirectusUser(null); + setAuthState('unauthenticated'); + } + }, + [directus] + ); + + const logout = React.useCallback(async () => { + try { + await directus.auth.logout(); + } finally { + setAuthState('unauthenticated'); + setDirectusUser(null); + } + }, [directus]); + + const value = React.useMemo( + () => ({ + user: directusUser, + authState, + login, + logout, + }), + [directus, directusUser, authState] + ); + + return value; +}; diff --git a/src/index.ts b/src/index.ts index 00a88336..4fb01065 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { DirectusProvider, useDirectus } from '@/DirectusProvider'; export { DirectusAsset } from '@components/DirectusAsset'; export { DirectusImage } from '@components/DirectusImage'; +export { useDirectusAuth } from '@hooks/useDirectusAuth'; diff --git a/src/types.ts b/src/types.ts index db2c382e..47bee383 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { DirectusOptions, IDirectus, TypeMap } from '@directus/sdk'; +import { DirectusOptions, IDirectus, TypeMap, UserType } from '@directus/sdk'; import { DirectusAsset } from '@components/DirectusAsset'; import { DirectusImage } from '@components/DirectusImage'; @@ -61,9 +61,16 @@ export interface DirectusProviderProps { apiUrl: string; /** A set of options to pass to the Directus client. */ options?: DirectusOptions; + /** + * If `true`, the provider will try to login the user automatically on mount. + * @default false + */ + autoLogin?: boolean; children: React.ReactNode; } +export type AuthStates = 'loading' | 'authenticated' | 'unauthenticated'; + /** * Shape of the main context. */ @@ -75,6 +82,55 @@ export interface DirectusContextType { DirectusAsset: typeof DirectusAsset; /** The context-aware `DirectusImage` component, with pre-filled props. */ DirectusImage: typeof DirectusImage; + /** + * Please use the data provided by the `useDirectusAuth` hook instead. + * @default 'loading' + * @internal + */ + _authState: AuthStates; + /** + * Please use the functions provided by the `useDirectusAuth` hook instead. + * @internal + */ + _setAuthState: React.Dispatch>; + /** + * Please use the data provided by the `useDirectusAuth` hook instead. + * @default null + * @internal + */ + _directusUser: UserType | null; + /** + * Please use the functions provided by the `useDirectusAuth` hook instead. + * @internal + */ + _setDirecctusUser: React.Dispatch>; } export type DirectusContextTypeGeneric = DirectusContextType | null; + +export interface DirectusAuthHook { + /** + * Login the user. If successful, the user will be stored in the context. + * Else, an error will be thrown. + * @param email - The user email. + * @param password - The user password. + * @throws {Error} - If the login fails. + */ + login: (email: string, password: string) => Promise; + /** + * Logout the user. If successful, the user will be removed from the context. + * Else, an error will be thrown. + * @throws {Error} - If the logout fails. + */ + logout: () => Promise; + /** + * Represents the current authentication state. + * @default 'loading' + */ + authState: AuthStates; + /** + * The current authenticated user. + * @default null + */ + user: UserType | null; +} diff --git a/tsconfig.json b/tsconfig.json index c0a2bf0d..0702e4d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "paths": { "@/*": ["*"], "@components/*": ["components/*"], + "@hooks/*": ["hooks/*"] } } }