From 2fc03f196e4e5fa0ad3369ec976c0b6889ebda88 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 17 Jul 2023 09:35:34 -0400 Subject: [PATCH] feat: returns queried user alongside refreshed token (#2813) Co-authored-by: Dan Ribbens --- src/admin/components/utilities/Auth/index.tsx | 110 ++++++++++++------ src/admin/components/utilities/Auth/types.ts | 4 +- .../components/views/Account/Default.tsx | 15 ++- src/admin/components/views/Account/index.tsx | 24 ++-- .../views/collections/Edit/Auth/index.tsx | 2 +- .../views/collections/Edit/Default.tsx | 10 +- .../views/collections/Edit/index.tsx | 57 ++++++--- src/auth/operations/getFieldsToSign.ts | 39 +++++++ src/auth/operations/login.ts | 26 +---- src/auth/operations/refresh.ts | 50 +++++--- test/auth/AuthDebug.tsx | 29 +++++ test/auth/config.ts | 17 +++ test/auth/e2e.spec.ts | 16 ++- test/auth/int.spec.ts | 34 +++++- test/auth/ui/AuthDebug.tsx | 28 +++++ test/fields/config.ts | 1 - 16 files changed, 350 insertions(+), 112 deletions(-) create mode 100644 src/auth/operations/getFieldsToSign.ts create mode 100644 test/auth/AuthDebug.tsx create mode 100644 test/auth/ui/AuthDebug.tsx diff --git a/src/admin/components/utilities/Auth/index.tsx b/src/admin/components/utilities/Auth/index.tsx index 910520be24..56738e8fe7 100644 --- a/src/admin/components/utilities/Auth/index.tsx +++ b/src/admin/components/utilities/Auth/index.tsx @@ -3,6 +3,7 @@ import jwtDecode from 'jwt-decode'; import { useHistory, useLocation } from 'react-router-dom'; import { useModal } from '@faceless-ui/modal'; import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; import { Permissions, User } from '../../../../auth/types'; import { useConfig } from '../Config'; import { requests } from '../../../api'; @@ -44,29 +45,56 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const id = user?.id; - const refreshCookie = useCallback(() => { + const refreshCookie = useCallback((forceRefresh?: boolean) => { const now = Math.round((new Date()).getTime() / 1000); const remainingTime = (exp as number || 0) - now; - if (exp && remainingTime < 120) { + if (forceRefresh || (exp && remainingTime < 120)) { setTimeout(async () => { - const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`, { - headers: { - 'Accept-Language': i18n.language, - }, - }); + try { + const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`, { + headers: { + 'Accept-Language': i18n.language, + }, + }); - if (request.status === 200) { - const json = await request.json(); - setUser(json.user); - } else { - setUser(null); - push(`${admin}${logoutInactivityRoute}?redirect=${encodeURIComponent(window.location.pathname)}`); + if (request.status === 200) { + const json = await request.json(); + setUser(json.user); + } else { + setUser(null); + push(`${admin}${logoutInactivityRoute}?redirect=${encodeURIComponent(window.location.pathname)}`); + } + } catch (e) { + toast.error(e.message); } }, 1000); } }, [exp, serverURL, api, userSlug, push, admin, logoutInactivityRoute, i18n]); + const refreshCookieAsync = useCallback(async (skipSetUser?: boolean): Promise => { + try { + const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`, { + headers: { + 'Accept-Language': i18n.language, + }, + }); + + if (request.status === 200) { + const json = await request.json(); + if (!skipSetUser) setUser(json.user); + return json.user; + } + + setUser(null); + push(`${admin}${logoutInactivityRoute}`); + return null; + } catch (e) { + toast.error(`Refreshing token failed: ${e.message}`); + return null; + } + }, [serverURL, api, userSlug, push, admin, logoutInactivityRoute, i18n]); + const setToken = useCallback((token: string) => { const decoded = jwtDecode(token); setUser(decoded); @@ -80,37 +108,47 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, [serverURL, api, userSlug]); const refreshPermissions = useCallback(async () => { - const request = await requests.get(`${serverURL}${api}/access`, { - headers: { - 'Accept-Language': i18n.language, - }, - }); - - if (request.status === 200) { - const json: Permissions = await request.json(); - setPermissions(json); - } else { - throw new Error(`Fetching permissions failed with status code ${request.status}`); - } - }, [serverURL, api, i18n]); - - // On mount, get user and set - useEffect(() => { - const fetchMe = async () => { - const request = await requests.get(`${serverURL}${api}/${userSlug}/me`, { + try { + const request = await requests.get(`${serverURL}${api}/access`, { headers: { 'Accept-Language': i18n.language, }, }); if (request.status === 200) { - const json = await request.json(); + const json: Permissions = await request.json(); + setPermissions(json); + } else { + throw new Error(`Fetching permissions failed with status code ${request.status}`); + } + } catch (e) { + toast.error(`Refreshing permissions failed: ${e.message}`); + } + }, [serverURL, api, i18n]); - setUser(json?.user || null); + // On mount, get user and set + useEffect(() => { + const fetchMe = async () => { + try { + const request = await requests.get(`${serverURL}${api}/${userSlug}/me`, { + headers: { + 'Accept-Language': i18n.language, + }, + }); - if (json?.token) { - setToken(json.token); + if (request.status === 200) { + const json = await request.json(); + + if (json?.user) { + setUser(json.user); + } else if (json?.token) { + setToken(json.token); + } else { + setUser(null); + } } + } catch (e) { + toast.error(`Fetching user failed: ${e.message}`); } }; @@ -172,8 +210,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children return ( = { user?: T | null + setUser: (user: T) => void logOut: () => void - refreshCookie: () => void + refreshCookie: (forceRefresh?: boolean) => void + refreshCookieAsync: () => Promise setToken: (token: string) => void token?: string refreshPermissions: () => Promise diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx index 902d4b85a8..1ea8c0df67 100644 --- a/src/admin/components/views/Account/Default.tsx +++ b/src/admin/components/views/Account/Default.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useConfig } from '../../utilities/Config'; @@ -22,6 +22,7 @@ import Label from '../../forms/Label'; import type { Translation } from '../../../../translations/type'; import { LoadingOverlayToggle } from '../../elements/Loading'; import { formatDate } from '../../../utilities/formatDate'; +import { useAuth } from '../../utilities/Auth'; import './index.scss'; @@ -37,7 +38,7 @@ const DefaultAccount: React.FC = (props) => { initialState, isLoading, action, - onSave, + onSave: onSaveFromProps, } = props; const { @@ -51,6 +52,7 @@ const DefaultAccount: React.FC = (props) => { auth, } = collection; + const { refreshCookieAsync } = useAuth(); const { admin: { dateFormat }, routes: { admin } } = useConfig(); const { t, i18n } = useTranslation('authentication'); @@ -58,6 +60,13 @@ const DefaultAccount: React.FC = (props) => { { label: (resource as Translation).general.thisLanguage, value: language } )); + const onSave = useCallback(async () => { + await refreshCookieAsync(); + if (typeof onSaveFromProps === 'function') { + onSaveFromProps(); + } + }, [onSaveFromProps, refreshCookieAsync]); + const classes = [ baseClass, ].filter(Boolean).join(' '); @@ -69,7 +78,6 @@ const DefaultAccount: React.FC = (props) => { show={isLoading} type="withoutNav" /> -
{!isLoading && ( @@ -201,7 +209,6 @@ const DefaultAccount: React.FC = (props) => { )} )} -
diff --git a/src/admin/components/views/Account/index.tsx b/src/admin/components/views/Account/index.tsx index 78f5a1eee5..77c40746ff 100644 --- a/src/admin/components/views/Account/index.tsx +++ b/src/admin/components/views/Account/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useConfig } from '../../utilities/Config'; @@ -18,7 +18,8 @@ const AccountView: React.FC = () => { const locale = useLocale(); const { setStepNav } = useStepNav(); const { user } = useAuth(); - const [initialState, setInitialState] = useState(); + const userRef = useRef(user); + const [internalState, setInternalState] = useState(); const { id, preferencesKey, docPermissions, getDocPermissions, slug, getDocPreferences } = useDocumentInfo(); const { getPreference } = usePreferences(); @@ -36,6 +37,7 @@ const AccountView: React.FC = () => { } = {}, }, } = useConfig(); + const { t } = useTranslation('authentication'); const collection = collections.find((coll) => coll.slug === slug); @@ -63,7 +65,7 @@ const AccountView: React.FC = () => { getDocPermissions(); const preferences = await getDocPreferences(); const state = await buildStateFromSchema({ fieldSchema: collection.fields, preferences, data: json.doc, user, id, operation: 'update', locale, t }); - setInitialState(state); + setInternalState(state); }, [collection, user, id, t, locale, getDocPermissions, getDocPreferences]); useEffect(() => { @@ -75,26 +77,28 @@ const AccountView: React.FC = () => { }, [setStepNav, t]); useEffect(() => { - const awaitInitialState = async () => { + const awaitInternalState = async () => { const preferences = await getDocPreferences(); + const state = await buildStateFromSchema({ fieldSchema: fields, preferences, data: dataToRender, operation: 'update', id, - user, + user: userRef.current, locale, t, }); + await getPreference(preferencesKey); - setInitialState(state); + setInternalState(state); }; - if (dataToRender) awaitInitialState(); - }, [dataToRender, fields, id, user, locale, preferencesKey, getPreference, t, getDocPreferences]); + if (dataToRender) awaitInternalState(); + }, [dataToRender, fields, id, locale, preferencesKey, getPreference, t, getDocPreferences]); - const isLoading = !initialState || !docPermissions || isLoadingData; + const isLoading = !internalState || !docPermissions || isLoadingData; return ( { collection, permissions: docPermissions, hasSavePermission, - initialState, + initialState: internalState, apiURL, isLoading, onSave, diff --git a/src/admin/components/views/collections/Edit/Auth/index.tsx b/src/admin/components/views/collections/Edit/Auth/index.tsx index 8ffc1cc581..20fe3afc45 100644 --- a/src/admin/components/views/collections/Edit/Auth/index.tsx +++ b/src/admin/components/views/collections/Edit/Auth/index.tsx @@ -73,7 +73,7 @@ const Auth: React.FC = (props) => { return (
- { !collection.auth.disableLocalStrategy && ( + {!collection.auth.disableLocalStrategy && ( = (props) => { const { admin: { dateFormat }, routes: { admin } } = useConfig(); const { publishedDoc } = useDocumentInfo(); const { t, i18n } = useTranslation('general'); + const { user, refreshCookieAsync } = useAuth(); const { collection, @@ -78,14 +80,18 @@ const DefaultEditView: React.FC = (props) => { isEditing && `${baseClass}--is-editing`, ].filter(Boolean).join(' '); - const onSave = useCallback((json) => { + const onSave = useCallback(async (json) => { + if (auth && id === user.id) { + await refreshCookieAsync(); + } + if (typeof onSaveFromProps === 'function') { onSaveFromProps({ ...json, operation: id ? 'update' : 'create', }); } - }, [id, onSaveFromProps]); + }, [id, onSaveFromProps, auth, user, refreshCookieAsync]); const operation = isEditing ? 'update' : 'create'; diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index 3cc42ac54d..e51095d6ef 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Redirect, useRouteMatch, useHistory, useLocation } from 'react-router-dom'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Redirect, useRouteMatch, useHistory } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useConfig } from '../../../utilities/Config'; import { useAuth } from '../../../utilities/Auth'; @@ -37,11 +37,11 @@ const EditView: React.FC = (props) => { const locale = useLocale(); const { serverURL, routes: { admin, api } } = useConfig(); const { params: { id } = {} } = useRouteMatch>(); - const { state: locationState } = useLocation(); const history = useHistory(); const [internalState, setInternalState] = useState(); const [updatedAt, setUpdatedAt] = useState(); const { user } = useAuth(); + const userRef = useRef(user); const { getVersions, getDocPermissions, docPermissions, getDocPreferences } = useDocumentInfo(); const { t } = useTranslation('general'); @@ -50,6 +50,24 @@ const EditView: React.FC = (props) => { { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' }, initialData: null }, ); + const buildState = useCallback(async (doc, overrides?: Partial[0]>) => { + const preferences = await getDocPreferences(); + + const state = await buildStateFromSchema({ + fieldSchema: overrides.fieldSchema, + preferences, + data: doc || {}, + user: userRef.current, + id, + operation: 'update', + locale, + t, + ...overrides, + }); + + setInternalState(state); + }, [getDocPreferences, id, locale, t]); + const onSave = useCallback(async (json: { doc }) => { @@ -59,24 +77,25 @@ const EditView: React.FC = (props) => { if (!isEditing) { setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); } else { - const preferences = await getDocPreferences(); - const state = await buildStateFromSchema({ fieldSchema: collection.fields, preferences, data: json.doc, user, id, operation: 'update', locale, t }); - setInternalState(state); + buildState(json.doc, { + fieldSchema: collection.fields, + }); } - }, [admin, collection.fields, collection.slug, getDocPreferences, getDocPermissions, getVersions, id, isEditing, locale, t, user]); - - const dataToRender = (locationState as Record)?.data || data; + }, [admin, getVersions, isEditing, buildState, getDocPermissions, collection]); useEffect(() => { - const awaitInternalState = async () => { - setUpdatedAt(dataToRender?.updatedAt); - const preferences = await getDocPreferences(); - const state = await buildStateFromSchema({ fieldSchema: fields, preferences, data: dataToRender || {}, user, operation: isEditing ? 'update' : 'create', id, locale, t }); - setInternalState(state); - }; + if (fields && (isEditing ? data : true)) { + const awaitInternalState = async () => { + setUpdatedAt(data?.updatedAt); + buildState(data, { + operation: isEditing ? 'update' : 'create', + fieldSchema: fields, + }); + }; - if (!isEditing || dataToRender) awaitInternalState(); - }, [dataToRender, fields, isEditing, id, user, locale, t, getDocPreferences]); + awaitInternalState(); + } + }, [isEditing, data, buildState, fields]); useEffect(() => { if (redirect) { @@ -103,7 +122,7 @@ const EditView: React.FC = (props) => { componentProps={{ id, isLoading, - data: dataToRender, + data, collection, permissions: docPermissions, isEditing, @@ -112,7 +131,7 @@ const EditView: React.FC = (props) => { hasSavePermission, apiURL, action, - updatedAt: updatedAt || dataToRender?.updatedAt, + updatedAt: updatedAt || data?.updatedAt, }} /> diff --git a/src/auth/operations/getFieldsToSign.ts b/src/auth/operations/getFieldsToSign.ts new file mode 100644 index 0000000000..e5a86cf9e1 --- /dev/null +++ b/src/auth/operations/getFieldsToSign.ts @@ -0,0 +1,39 @@ +import { User } from '..'; +import { CollectionConfig } from '../../collections/config/types'; +import { Field, fieldAffectsData, fieldHasSubFields } from '../../fields/config/types'; + +export const getFieldsToSign = (args: { + collectionConfig: CollectionConfig, + user: User + email: string +}): Record => { + const { + collectionConfig, + user, + email, + } = args; + + return collectionConfig.fields.reduce((signedFields, field: Field) => { + const result = { + ...signedFields, + }; + + if (!fieldAffectsData(field) && fieldHasSubFields(field)) { + field.fields.forEach((subField) => { + if (fieldAffectsData(subField) && subField.saveToJWT) { + result[subField.name] = user[subField.name]; + } + }); + } + + if (fieldAffectsData(field) && field.saveToJWT) { + result[field.name] = user[field.name]; + } + + return result; + }, { + email, + id: user.id, + collection: collectionConfig.slug, + }); +}; diff --git a/src/auth/operations/login.ts b/src/auth/operations/login.ts index 2d6c11d042..8f9bb27f4d 100644 --- a/src/auth/operations/login.ts +++ b/src/auth/operations/login.ts @@ -6,13 +6,13 @@ import { PayloadRequest } from '../../express/types'; import getCookieExpiration from '../../utilities/getCookieExpiration'; import isLocked from '../isLocked'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; -import { Field, fieldHasSubFields, fieldAffectsData } from '../../fields/config/types'; import { User } from '../types'; import { Collection } from '../../collections/config/types'; import { afterRead } from '../../fields/hooks/afterRead'; import unlock from './unlock'; import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts'; import { authenticateLocalStrategy } from '../strategies/local/authenticate'; +import { getFieldsToSign } from './getFieldsToSign'; export type Result = { user?: User, @@ -121,28 +121,10 @@ async function login( }); } - const fieldsToSign = collectionConfig.fields.reduce((signedFields, field: Field) => { - const result = { - ...signedFields, - }; - - if (!fieldAffectsData(field) && fieldHasSubFields(field)) { - field.fields.forEach((subField) => { - if (fieldAffectsData(subField) && subField.saveToJWT) { - result[subField.name] = user[subField.name]; - } - }); - } - - if (fieldAffectsData(field) && field.saveToJWT) { - result[field.name] = user[field.name]; - } - - return result; - }, { + const fieldsToSign = getFieldsToSign({ + collectionConfig, + user, email, - id: user.id, - collection: collectionConfig.slug, }); await collectionConfig.hooks.beforeLogin.reduce(async (priorHook, hook) => { diff --git a/src/auth/operations/refresh.ts b/src/auth/operations/refresh.ts index ca85ef1109..635a87ee1e 100644 --- a/src/auth/operations/refresh.ts +++ b/src/auth/operations/refresh.ts @@ -1,10 +1,12 @@ import jwt from 'jsonwebtoken'; import { Response } from 'express'; +import url from 'url'; import { Collection, BeforeOperationHook } from '../../collections/config/types'; import { Forbidden } from '../../errors'; import getCookieExpiration from '../../utilities/getCookieExpiration'; import { Document } from '../../types'; import { PayloadRequest } from '../../express/types'; +import { getFieldsToSign } from './getFieldsToSign'; export type Result = { exp: number, @@ -51,16 +53,32 @@ async function refresh(incomingArgs: Arguments): Promise { }, } = args; - const opts = { - expiresIn: args.collection.config.auth.tokenExpiration, - }; - if (typeof args.token !== 'string') throw new Forbidden(args.req.t); - const payload = jwt.verify(args.token, secret, {}) as Record; - delete payload.iat; - delete payload.exp; - const refreshedToken = jwt.sign(payload, secret, opts); + const parsedURL = url.parse(args.req.url); + const isGraphQL = parsedURL.pathname === config.routes.graphQL; + + const user = await args.req.payload.findByID({ + id: args.req.user.id, + collection: args.req.user.collection, + req: args.req, + depth: isGraphQL ? 0 : args.collection.config.auth.depth, + }); + + const fieldsToSign = getFieldsToSign({ + collectionConfig, + user: args?.req?.user, + email: user?.email as string, + }); + + const refreshedToken = jwt.sign( + fieldsToSign, + secret, + { + expiresIn: collectionConfig.auth.tokenExpiration, + }, + ); + const exp = (jwt.decode(refreshedToken) as Record).exp as number; if (args.res) { @@ -78,6 +96,12 @@ async function refresh(incomingArgs: Arguments): Promise { args.res.cookie(`${config.cookiePrefix}-token`, refreshedToken, cookieOptions); } + let response: Result = { + user, + refreshedToken, + exp, + }; + // ///////////////////////////////////// // After Refresh - Collection // ///////////////////////////////////// @@ -85,23 +109,19 @@ async function refresh(incomingArgs: Arguments): Promise { await collectionConfig.hooks.afterRefresh.reduce(async (priorHook, hook) => { await priorHook; - args = (await hook({ + response = (await hook({ req: args.req, res: args.res, exp, token: refreshedToken, - })) || args; + })) || response; }, Promise.resolve()); // ///////////////////////////////////// // Return results // ///////////////////////////////////// - return { - refreshedToken, - exp, - user: payload, - }; + return response; } export default refresh; diff --git a/test/auth/AuthDebug.tsx b/test/auth/AuthDebug.tsx new file mode 100644 index 0000000000..d0de538b23 --- /dev/null +++ b/test/auth/AuthDebug.tsx @@ -0,0 +1,29 @@ +import React, { useEffect, useState } from 'react'; +import { useAuth } from '../../src/admin/components/utilities/Auth'; +import { User } from '../../src/auth'; +import { UIField } from '../../src/fields/config/types'; + +export const AuthDebug: React.FC = () => { + const [state, setState] = useState(); + const { user } = useAuth(); + + useEffect(() => { + const fetchUser = async () => { + const userRes = await fetch(`/api/users/${user?.id}`)?.then((res) => res.json()); + setState(userRes); + }; + + fetchUser(); + }, [user]); + + return ( +
+
+ {user?.custom as string} +
+
+ {state?.custom as string} +
+
+ ); +}; diff --git a/test/auth/config.ts b/test/auth/config.ts index 19810289aa..3abef71eaa 100644 --- a/test/auth/config.ts +++ b/test/auth/config.ts @@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid'; import { mapAsync } from '../../src/utilities/mapAsync'; import { buildConfig } from '../buildConfig'; import { devUser } from '../credentials'; +import { AuthDebug } from './AuthDebug'; export const slug = 'users'; @@ -36,6 +37,21 @@ export default buildConfig({ saveToJWT: true, hasMany: true, }, + { + name: 'custom', + label: 'Custom', + type: 'text', + }, + { + name: 'authDebug', + label: 'Auth Debug', + type: 'ui', + admin: { + components: { + Field: AuthDebug, + }, + }, + }, ], }, { @@ -65,6 +81,7 @@ export default buildConfig({ data: { email: devUser.email, password: devUser.password, + custom: 'Hello, world!', }, }); diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index f05a655580..a567a6619c 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -1,5 +1,5 @@ import type { Page } from '@playwright/test'; -import { expect, test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { AdminUrlUtil } from '../helpers/adminUrlUtil'; import { initPayloadE2E } from '../helpers/configHelpers'; import { login, saveDocAndAssert } from '../helpers'; @@ -42,5 +42,19 @@ describe('auth', () => { await saveDocAndAssert(page); }); + + test('should have up-to-date user in `useAuth` hook', async () => { + await page.goto(url.account); + + await expect(await page.locator('#users-api-result')).toHaveText('Hello, world!'); + await expect(await page.locator('#use-auth-result')).toHaveText('Hello, world!'); + + const field = await page.locator('#field-custom'); + await field.fill('Goodbye, world!'); + await saveDocAndAssert(page); + + await expect(await page.locator('#users-api-result')).toHaveText('Goodbye, world!'); + await expect(await page.locator('#use-auth-result')).toHaveText('Goodbye, world!'); + }); }); }); diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index cc61b8df7d..6d5cd1547f 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -3,6 +3,7 @@ import payload from '../../src'; import { initPayloadTest } from '../helpers/configHelpers'; import { slug } from './config'; import { devUser } from '../credentials'; +import type { User } from '../../src/auth'; require('isomorphic-fetch'); @@ -11,6 +12,7 @@ let apiUrl; const headers = { 'Content-Type': 'application/json', }; + const { email, password } = devUser; describe('Auth', () => { @@ -68,6 +70,8 @@ describe('Auth', () => { describe('logged in', () => { let token: string | undefined; + let loggedInUser: User | undefined; + beforeAll(async () => { const response = await fetch(`${apiUrl}/${slug}/login`, { body: JSON.stringify({ @@ -80,6 +84,7 @@ describe('Auth', () => { const data = await response.json(); token = data.token; + loggedInUser = data.user; }); it('should return a logged in user from /me', async () => { @@ -99,6 +104,7 @@ describe('Auth', () => { it('should allow authentication with an API key with useAPIKey', async () => { const apiKey = '0123456789ABCDEFGH'; + const user = await payload.create({ collection: slug, data: { @@ -107,10 +113,11 @@ describe('Auth', () => { apiKey, }, }); + const response = await fetch(`${apiUrl}/${slug}/me`, { headers: { ...headers, - Authorization: `${slug} API-Key ${user.apiKey}`, + Authorization: `${slug} API-Key ${user?.apiKey}`, }, }); @@ -135,6 +142,30 @@ describe('Auth', () => { expect(data.refreshedToken).toBeDefined(); }); + it('should refresh a token and receive an up-to-date user', async () => { + expect(loggedInUser?.custom).toBe('Hello, world!'); + + await payload.update({ + collection: slug, + id: loggedInUser?.id || '', + data: { + custom: 'Goodbye, world!', + }, + }); + + const response = await fetch(`${apiUrl}/${slug}/refresh-token`, { + method: 'post', + headers: { + Authorization: `JWT ${token}`, + }, + }); + + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.user.custom).toBe('Goodbye, world!'); + }); + it('should allow a user to be created', async () => { const response = await fetch(`${apiUrl}/${slug}`, { body: JSON.stringify({ @@ -201,6 +232,7 @@ describe('Auth', () => { expect(verificationResponse.status).toBe(200); const afterVerifyResult = await db.collection('public-users').findOne({ email: emailToVerify }); + // @ts-expect-error trust const { _verified: afterVerified, _verificationToken: afterToken } = afterVerifyResult; expect(afterVerified).toBe(true); expect(afterToken).toBeUndefined(); diff --git a/test/auth/ui/AuthDebug.tsx b/test/auth/ui/AuthDebug.tsx new file mode 100644 index 0000000000..2c52ba9b56 --- /dev/null +++ b/test/auth/ui/AuthDebug.tsx @@ -0,0 +1,28 @@ +import React, { useEffect, useState } from 'react'; +import { useAuth } from '../../../src/admin/components/utilities/Auth'; +import { UIField } from '../../../src/fields/config/types'; +import { User } from '../../../src/auth'; + +export const AuthDebug: React.FC = () => { + const [state, setState] = useState(); + const { user } = useAuth(); + + useEffect(() => { + if (user) { + fetch(`/api/users/${user.id}`).then((r) => r.json()).then((newUser) => { + setState(newUser); + }); + } + }, [user]); + + return ( +
+
+ {state?.custom as string} +
+
+ {user?.custom as string} +
+
+ ); +}; diff --git a/test/fields/config.ts b/test/fields/config.ts index c72069da41..85aea71011 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -51,7 +51,6 @@ export default buildConfig({ name: 'canViewConditionalField', type: 'checkbox', defaultValue: true, - saveToJWT: true, }, ], },