fix(ui): ensure document unlocks when logging out from edit view of a locked document (#13142)
### What? Refactors the `LeaveWithoutSaving` modal to be generic and delegates document unlock logic back to the `DefaultEditView` component via a callback. ### Why? Previously, `unlockDocument` was triggered in a cleanup `useEffect` in the edit view. When logging out from the edit view, the unlock request would often fail due to the session ending — leaving the document in a locked state. ### How? - Introduced `onConfirm` and `onPrevent` props for `LeaveWithoutSaving`. - Moved all document lock/unlock logic into `DefaultEditView`’s `handleLeaveConfirm`. - Captures the next navigation target via `onPrevent` and evaluates whether to unlock based on: - Locking being enabled. - Current user owning the lock. - Navigation not targeting internal admin views (`/preview`, `/api`, `/versions`). --------- Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
This commit is contained in:
@@ -85,7 +85,14 @@ export const CreateFirstUserClient: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
action={`${serverURL}${apiRoute}/${userSlug}/first-register`}
|
action={`${serverURL}${apiRoute}/${userSlug}/first-register`}
|
||||||
initialState={initialState}
|
initialState={{
|
||||||
|
...initialState,
|
||||||
|
'confirm-password': {
|
||||||
|
...initialState['confirm-password'],
|
||||||
|
valid: initialState['confirm-password']['valid'] || false,
|
||||||
|
value: initialState['confirm-password']['value'] || '',
|
||||||
|
},
|
||||||
|
}}
|
||||||
method="POST"
|
method="POST"
|
||||||
onChange={[onChange]}
|
onChange={[onChange]}
|
||||||
onSuccess={handleFirstRegister}
|
onSuccess={handleFirstRegister}
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import { usePreventLeave } from './usePreventLeave.js'
|
|||||||
|
|
||||||
const modalSlug = 'leave-without-saving'
|
const modalSlug = 'leave-without-saving'
|
||||||
|
|
||||||
export const LeaveWithoutSaving: React.FC = () => {
|
type LeaveWithoutSavingProps = {
|
||||||
|
onConfirm?: () => Promise<void> | void
|
||||||
|
onPrevent?: (nextHref: null | string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LeaveWithoutSaving: React.FC<LeaveWithoutSavingProps> = ({ onConfirm, onPrevent }) => {
|
||||||
const { closeModal, openModal } = useModal()
|
const { closeModal, openModal } = useModal()
|
||||||
const modified = useFormModified()
|
const modified = useFormModified()
|
||||||
const { isValid } = useForm()
|
const { isValid } = useForm()
|
||||||
@@ -22,23 +27,34 @@ export const LeaveWithoutSaving: React.FC = () => {
|
|||||||
|
|
||||||
const prevent = Boolean((modified || !isValid) && user)
|
const prevent = Boolean((modified || !isValid) && user)
|
||||||
|
|
||||||
const onPrevent = useCallback(() => {
|
const handlePrevent = useCallback(() => {
|
||||||
|
const activeHref = (document.activeElement as HTMLAnchorElement)?.href || null
|
||||||
|
if (onPrevent) {
|
||||||
|
onPrevent(activeHref)
|
||||||
|
}
|
||||||
openModal(modalSlug)
|
openModal(modalSlug)
|
||||||
}, [openModal])
|
}, [openModal, onPrevent])
|
||||||
|
|
||||||
const handleAccept = useCallback(() => {
|
const handleAccept = useCallback(() => {
|
||||||
closeModal(modalSlug)
|
closeModal(modalSlug)
|
||||||
}, [closeModal])
|
}, [closeModal])
|
||||||
|
|
||||||
usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent, prevent })
|
usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent: handlePrevent, prevent })
|
||||||
|
|
||||||
const onCancel: OnCancel = useCallback(() => {
|
const onCancel: OnCancel = useCallback(() => {
|
||||||
closeModal(modalSlug)
|
closeModal(modalSlug)
|
||||||
}, [closeModal])
|
}, [closeModal])
|
||||||
|
|
||||||
const onConfirm = useCallback(() => {
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
try {
|
||||||
|
await onConfirm()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in LeaveWithoutSaving onConfirm:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
setHasAccepted(true)
|
setHasAccepted(true)
|
||||||
}, [])
|
}, [onConfirm])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
@@ -48,7 +64,7 @@ export const LeaveWithoutSaving: React.FC = () => {
|
|||||||
heading={t('general:leaveWithoutSaving')}
|
heading={t('general:leaveWithoutSaving')}
|
||||||
modalSlug={modalSlug}
|
modalSlug={modalSlug}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={handleConfirm}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const RenderTitle: React.FC<RenderTitleProps> = (props) => {
|
|||||||
className={[className, baseClass, idAsTitle && `${baseClass}--has-id`]
|
className={[className, baseClass, idAsTitle && `${baseClass}--has-id`]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
|
data-doc-id={id}
|
||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
{isInitializing ? (
|
{isInitializing ? (
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ const DocumentInfo: React.FC<
|
|||||||
'idle',
|
'idle',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const documentLockState = useRef<{
|
||||||
|
hasShownLockedModal: boolean
|
||||||
|
isLocked: boolean
|
||||||
|
user: ClientUser | number | string
|
||||||
|
} | null>({
|
||||||
|
hasShownLockedModal: false,
|
||||||
|
isLocked: false,
|
||||||
|
user: null,
|
||||||
|
})
|
||||||
|
|
||||||
const updateUploadStatus = useCallback(
|
const updateUploadStatus = useCallback(
|
||||||
(status: 'failed' | 'idle' | 'uploading') => {
|
(status: 'failed' | 'idle' | 'uploading') => {
|
||||||
setUploadStatus(status)
|
setUploadStatus(status)
|
||||||
@@ -344,6 +354,7 @@ const DocumentInfo: React.FC<
|
|||||||
docConfig,
|
docConfig,
|
||||||
docPermissions,
|
docPermissions,
|
||||||
documentIsLocked,
|
documentIsLocked,
|
||||||
|
documentLockState,
|
||||||
getDocPermissions,
|
getDocPermissions,
|
||||||
getDocPreferences,
|
getDocPreferences,
|
||||||
hasPublishedDoc,
|
hasPublishedDoc,
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export type DocumentInfoContext = {
|
|||||||
currentEditor?: ClientUser | null | number | string
|
currentEditor?: ClientUser | null | number | string
|
||||||
docConfig?: ClientCollectionConfig | ClientGlobalConfig
|
docConfig?: ClientCollectionConfig | ClientGlobalConfig
|
||||||
documentIsLocked?: boolean
|
documentIsLocked?: boolean
|
||||||
|
documentLockState: React.RefObject<{
|
||||||
|
hasShownLockedModal: boolean
|
||||||
|
isLocked: boolean
|
||||||
|
user: ClientUser | number | string
|
||||||
|
} | null>
|
||||||
getDocPermissions: (data?: Data) => Promise<void>
|
getDocPermissions: (data?: Data) => Promise<void>
|
||||||
getDocPreferences: () => Promise<DocumentPreferences>
|
getDocPreferences: () => Promise<DocumentPreferences>
|
||||||
incrementVersionCount: () => void
|
incrementVersionCount: () => void
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function DefaultEditView({
|
|||||||
disableLeaveWithoutSaving,
|
disableLeaveWithoutSaving,
|
||||||
docPermissions,
|
docPermissions,
|
||||||
documentIsLocked,
|
documentIsLocked,
|
||||||
|
documentLockState,
|
||||||
getDocPermissions,
|
getDocPermissions,
|
||||||
getDocPreferences,
|
getDocPreferences,
|
||||||
globalSlug,
|
globalSlug,
|
||||||
@@ -164,16 +165,6 @@ export function DefaultEditView({
|
|||||||
|
|
||||||
const isLockExpired = Date.now() > lockExpiryTime
|
const isLockExpired = Date.now() > lockExpiryTime
|
||||||
|
|
||||||
const documentLockStateRef = useRef<{
|
|
||||||
hasShownLockedModal: boolean
|
|
||||||
isLocked: boolean
|
|
||||||
user: ClientUser | number | string
|
|
||||||
} | null>({
|
|
||||||
hasShownLockedModal: false,
|
|
||||||
isLocked: false,
|
|
||||||
user: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const schemaPathSegments = useMemo(() => [entitySlug], [entitySlug])
|
const schemaPathSegments = useMemo(() => [entitySlug], [entitySlug])
|
||||||
|
|
||||||
const [validateBeforeSubmit, setValidateBeforeSubmit] = useState(() => {
|
const [validateBeforeSubmit, setValidateBeforeSubmit] = useState(() => {
|
||||||
@@ -184,13 +175,15 @@ export function DefaultEditView({
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const nextHrefRef = React.useRef<null | string>(null)
|
||||||
|
|
||||||
const handleDocumentLocking = useCallback(
|
const handleDocumentLocking = useCallback(
|
||||||
(lockedState: LockedState) => {
|
(lockedState: LockedState) => {
|
||||||
setDocumentIsLocked(true)
|
setDocumentIsLocked(true)
|
||||||
const previousOwnerID =
|
const previousOwnerID =
|
||||||
typeof documentLockStateRef.current?.user === 'object'
|
typeof documentLockState.current?.user === 'object'
|
||||||
? documentLockStateRef.current?.user?.id
|
? documentLockState.current?.user?.id
|
||||||
: documentLockStateRef.current?.user
|
: documentLockState.current?.user
|
||||||
|
|
||||||
if (lockedState) {
|
if (lockedState) {
|
||||||
const lockedUserID =
|
const lockedUserID =
|
||||||
@@ -198,14 +191,14 @@ export function DefaultEditView({
|
|||||||
? lockedState.user
|
? lockedState.user
|
||||||
: lockedState.user.id
|
: lockedState.user.id
|
||||||
|
|
||||||
if (!documentLockStateRef.current || lockedUserID !== previousOwnerID) {
|
if (!documentLockState.current || lockedUserID !== previousOwnerID) {
|
||||||
if (previousOwnerID === user.id && lockedUserID !== user.id) {
|
if (previousOwnerID === user.id && lockedUserID !== user.id) {
|
||||||
setShowTakeOverModal(true)
|
setShowTakeOverModal(true)
|
||||||
documentLockStateRef.current.hasShownLockedModal = true
|
documentLockState.current.hasShownLockedModal = true
|
||||||
}
|
}
|
||||||
|
|
||||||
documentLockStateRef.current = {
|
documentLockState.current = {
|
||||||
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal || false,
|
hasShownLockedModal: documentLockState.current?.hasShownLockedModal || false,
|
||||||
isLocked: true,
|
isLocked: true,
|
||||||
user: lockedState.user as ClientUser,
|
user: lockedState.user as ClientUser,
|
||||||
}
|
}
|
||||||
@@ -213,9 +206,52 @@ export function DefaultEditView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setCurrentEditor, setDocumentIsLocked, user?.id],
|
[documentLockState, setCurrentEditor, setDocumentIsLocked, user?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handlePrevent = useCallback((nextHref: null | string) => {
|
||||||
|
nextHrefRef.current = nextHref
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLeaveConfirm = useCallback(async () => {
|
||||||
|
const lockUser = documentLockState.current?.user
|
||||||
|
|
||||||
|
const isLockOwnedByCurrentUser =
|
||||||
|
typeof lockUser === 'object' ? lockUser?.id === user?.id : lockUser === user?.id
|
||||||
|
|
||||||
|
if (isLockingEnabled && documentIsLocked && (id || globalSlug)) {
|
||||||
|
// Check where user is trying to go
|
||||||
|
const nextPath = nextHrefRef.current ? new URL(nextHrefRef.current).pathname : ''
|
||||||
|
const isInternalView = ['/preview', '/api', '/versions'].some((path) =>
|
||||||
|
nextPath.includes(path),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only retain the lock if the user is still viewing the document
|
||||||
|
if (!isInternalView) {
|
||||||
|
if (isLockOwnedByCurrentUser) {
|
||||||
|
try {
|
||||||
|
await unlockDocument(id, collectionSlug ?? globalSlug)
|
||||||
|
setDocumentIsLocked(false)
|
||||||
|
setCurrentEditor(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to unlock before leave', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
collectionSlug,
|
||||||
|
documentIsLocked,
|
||||||
|
documentLockState,
|
||||||
|
globalSlug,
|
||||||
|
id,
|
||||||
|
isLockingEnabled,
|
||||||
|
setCurrentEditor,
|
||||||
|
setDocumentIsLocked,
|
||||||
|
unlockDocument,
|
||||||
|
user?.id,
|
||||||
|
])
|
||||||
|
|
||||||
const onSave = useCallback(
|
const onSave = useCallback(
|
||||||
async (json): Promise<FormState> => {
|
async (json): Promise<FormState> => {
|
||||||
const controller = handleAbortRef(abortOnSaveRef)
|
const controller = handleAbortRef(abortOnSaveRef)
|
||||||
@@ -342,7 +378,7 @@ export function DefaultEditView({
|
|||||||
|
|
||||||
const docPreferences = await getDocPreferences()
|
const docPreferences = await getDocPreferences()
|
||||||
|
|
||||||
const { lockedState, state } = await getFormState({
|
const result = await getFormState({
|
||||||
id,
|
id,
|
||||||
collectionSlug,
|
collectionSlug,
|
||||||
docPermissions,
|
docPermissions,
|
||||||
@@ -360,6 +396,12 @@ export function DefaultEditView({
|
|||||||
updateLastEdited,
|
updateLastEdited,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lockedState, state } = result
|
||||||
|
|
||||||
if (isLockingEnabled) {
|
if (isLockingEnabled) {
|
||||||
handleDocumentLocking(lockedState)
|
handleDocumentLocking(lockedState)
|
||||||
}
|
}
|
||||||
@@ -386,38 +428,9 @@ export function DefaultEditView({
|
|||||||
// Clean up when the component unmounts or when the document is unlocked
|
// Clean up when the component unmounts or when the document is unlocked
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (isLockingEnabled && documentIsLocked && (id || globalSlug)) {
|
|
||||||
// Only retain the lock if the user is still viewing the document
|
|
||||||
const shouldUnlockDocument = !['preview', 'api', 'versions'].some((path) =>
|
|
||||||
window.location.pathname.includes(path),
|
|
||||||
)
|
|
||||||
if (shouldUnlockDocument) {
|
|
||||||
// Check if this user is still the current editor
|
|
||||||
if (
|
|
||||||
typeof documentLockStateRef.current?.user === 'object'
|
|
||||||
? documentLockStateRef.current?.user?.id === user?.id
|
|
||||||
: documentLockStateRef.current?.user === user?.id
|
|
||||||
) {
|
|
||||||
void unlockDocument(id, collectionSlug ?? globalSlug)
|
|
||||||
setDocumentIsLocked(false)
|
|
||||||
setCurrentEditor(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowTakeOverModal(false)
|
setShowTakeOverModal(false)
|
||||||
}
|
}
|
||||||
}, [
|
}, [])
|
||||||
collectionSlug,
|
|
||||||
globalSlug,
|
|
||||||
id,
|
|
||||||
unlockDocument,
|
|
||||||
user,
|
|
||||||
setCurrentEditor,
|
|
||||||
isLockingEnabled,
|
|
||||||
documentIsLocked,
|
|
||||||
setDocumentIsLocked,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abortOnChange = abortOnChangeRef.current
|
const abortOnChange = abortOnChangeRef.current
|
||||||
@@ -437,7 +450,7 @@ export function DefaultEditView({
|
|||||||
: currentEditor !== user?.id) &&
|
: currentEditor !== user?.id) &&
|
||||||
!isReadOnlyForIncomingUser &&
|
!isReadOnlyForIncomingUser &&
|
||||||
!showTakeOverModal &&
|
!showTakeOverModal &&
|
||||||
!documentLockStateRef.current?.hasShownLockedModal &&
|
!documentLockState.current?.hasShownLockedModal &&
|
||||||
!isLockExpired
|
!isLockExpired
|
||||||
|
|
||||||
const isFolderCollection = config.folders && collectionSlug === config.folders?.slug
|
const isFolderCollection = config.folders && collectionSlug === config.folders?.slug
|
||||||
@@ -487,7 +500,7 @@ export function DefaultEditView({
|
|||||||
false,
|
false,
|
||||||
updateDocumentEditor,
|
updateDocumentEditor,
|
||||||
setCurrentEditor,
|
setCurrentEditor,
|
||||||
documentLockStateRef,
|
documentLockState,
|
||||||
isLockingEnabled,
|
isLockingEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -505,7 +518,9 @@ export function DefaultEditView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isReadOnlyForIncomingUser && preventLeaveWithoutSaving && <LeaveWithoutSaving />}
|
{!isReadOnlyForIncomingUser && preventLeaveWithoutSaving && (
|
||||||
|
<LeaveWithoutSaving onConfirm={handleLeaveConfirm} onPrevent={handlePrevent} />
|
||||||
|
)}
|
||||||
{!isInDrawer && (
|
{!isInDrawer && (
|
||||||
<SetDocumentStepNav
|
<SetDocumentStepNav
|
||||||
collectionSlug={collectionConfig?.slug}
|
collectionSlug={collectionConfig?.slug}
|
||||||
@@ -552,7 +567,7 @@ export function DefaultEditView({
|
|||||||
true,
|
true,
|
||||||
updateDocumentEditor,
|
updateDocumentEditor,
|
||||||
setCurrentEditor,
|
setCurrentEditor,
|
||||||
documentLockStateRef,
|
documentLockState,
|
||||||
isLockingEnabled,
|
isLockingEnabled,
|
||||||
setIsReadOnlyForIncomingUser,
|
setIsReadOnlyForIncomingUser,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
import { v4 as uuid } from 'uuid'
|
|
||||||
|
|
||||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
||||||
import { devUser } from '../credentials.js'
|
import { devUser } from '../credentials.js'
|
||||||
|
import { seed } from './seed.js'
|
||||||
import {
|
import {
|
||||||
apiKeysSlug,
|
apiKeysSlug,
|
||||||
namedSaveToJWTValue,
|
namedSaveToJWTValue,
|
||||||
@@ -263,33 +262,7 @@ export default buildConfigWithDefaults({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onInit: async (payload) => {
|
onInit: seed,
|
||||||
await payload.create({
|
|
||||||
collection: 'users',
|
|
||||||
data: {
|
|
||||||
custom: 'Hello, world!',
|
|
||||||
email: devUser.email,
|
|
||||||
password: devUser.password,
|
|
||||||
roles: ['admin'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await payload.create({
|
|
||||||
collection: apiKeysSlug,
|
|
||||||
data: {
|
|
||||||
apiKey: uuid(),
|
|
||||||
enableAPIKey: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await payload.create({
|
|
||||||
collection: apiKeysSlug,
|
|
||||||
data: {
|
|
||||||
apiKey: uuid(),
|
|
||||||
enableAPIKey: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
typescript: {
|
typescript: {
|
||||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { BrowserContext, Page } from '@playwright/test'
|
import type { BrowserContext, Page } from '@playwright/test'
|
||||||
import type { SanitizedConfig } from 'payload'
|
|
||||||
|
|
||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { devUser } from 'credentials.js'
|
import { devUser } from 'credentials.js'
|
||||||
|
import { openNav } from 'helpers/e2e/toggleNav.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { v4 as uuid } from 'uuid'
|
import { v4 as uuid } from 'uuid'
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
exactText,
|
exactText,
|
||||||
getRoutes,
|
getRoutes,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
|
login,
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
} from '../helpers.js'
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
@@ -28,59 +29,12 @@ const dirname = path.dirname(filename)
|
|||||||
|
|
||||||
let payload: PayloadTestSDK<Config>
|
let payload: PayloadTestSDK<Config>
|
||||||
|
|
||||||
const { beforeAll, describe } = test
|
const { beforeAll, afterAll, describe } = test
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|
||||||
const createFirstUser = async ({
|
|
||||||
page,
|
|
||||||
serverURL,
|
|
||||||
}: {
|
|
||||||
customAdminRoutes?: SanitizedConfig['admin']['routes']
|
|
||||||
customRoutes?: SanitizedConfig['routes']
|
|
||||||
page: Page
|
|
||||||
serverURL: string
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
admin: {
|
|
||||||
routes: { createFirstUser: createFirstUserRoute },
|
|
||||||
},
|
|
||||||
routes: { admin: adminRoute },
|
|
||||||
} = getRoutes({})
|
|
||||||
|
|
||||||
// wait for create first user route
|
|
||||||
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
|
|
||||||
|
|
||||||
// forget to fill out confirm password
|
|
||||||
await page.locator('#field-email').fill(devUser.email)
|
|
||||||
await page.locator('#field-password').fill(devUser.password)
|
|
||||||
await page.locator('.form-submit > button').click()
|
|
||||||
await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText(
|
|
||||||
'This field is required.',
|
|
||||||
)
|
|
||||||
|
|
||||||
// make them match, but does not pass password validation
|
|
||||||
await page.locator('#field-email').fill(devUser.email)
|
|
||||||
await page.locator('#field-password').fill('12')
|
|
||||||
await page.locator('#field-confirm-password').fill('12')
|
|
||||||
await page.locator('.form-submit > button').click()
|
|
||||||
await expect(page.locator('.field-type.password .field-error')).toHaveText(
|
|
||||||
'This value must be longer than the minimum length of 3 characters.',
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.locator('#field-email').fill(devUser.email)
|
|
||||||
await page.locator('#field-password').fill(devUser.password)
|
|
||||||
await page.locator('#field-confirm-password').fill(devUser.password)
|
|
||||||
await page.locator('#field-custom').fill('Hello, world!')
|
|
||||||
await page.locator('.form-submit > button').click()
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
|
|
||||||
.not.toContain('create-first-user')
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Auth', () => {
|
describe('Auth', () => {
|
||||||
let page: Page
|
let page: Page
|
||||||
let context: BrowserContext
|
let context: BrowserContext
|
||||||
@@ -97,35 +51,92 @@ describe('Auth', () => {
|
|||||||
context = await browser.newContext()
|
context = await browser.newContext()
|
||||||
page = await context.newPage()
|
page = await context.newPage()
|
||||||
initPageConsoleErrorCatch(page)
|
initPageConsoleErrorCatch(page)
|
||||||
|
})
|
||||||
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
|
describe('create first user', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
// Undo onInit seeding, as we need to test this without having a user created, or testing create-first-user
|
|
||||||
await reInitializeDB({
|
await reInitializeDB({
|
||||||
serverURL,
|
serverURL,
|
||||||
snapshotKey: 'auth',
|
snapshotKey: 'create-first-user',
|
||||||
deleteOnly: true,
|
deleteOnly: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
await payload.create({
|
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
|
||||||
collection: apiKeysSlug,
|
|
||||||
data: {
|
await payload.delete({
|
||||||
apiKey: uuid(),
|
collection: slug,
|
||||||
enableAPIKey: true,
|
where: {
|
||||||
|
email: {
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await payload.create({
|
|
||||||
collection: apiKeysSlug,
|
|
||||||
data: {
|
|
||||||
apiKey: uuid(),
|
|
||||||
enableAPIKey: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await createFirstUser({ page, serverURL })
|
async function waitForVisibleAuthFields() {
|
||||||
|
await expect(page.locator('#field-email')).toBeVisible()
|
||||||
|
await expect(page.locator('#field-password')).toBeVisible()
|
||||||
|
await expect(page.locator('#field-confirm-password')).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
await ensureCompilationIsDone({ page, serverURL })
|
test('should create first user and redirect to admin', async () => {
|
||||||
|
const {
|
||||||
|
admin: {
|
||||||
|
routes: { createFirstUser: createFirstUserRoute },
|
||||||
|
},
|
||||||
|
routes: { admin: adminRoute },
|
||||||
|
} = getRoutes({})
|
||||||
|
|
||||||
|
// wait for create first user route
|
||||||
|
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
|
||||||
|
|
||||||
|
await expect(page.locator('.create-first-user')).toBeVisible()
|
||||||
|
|
||||||
|
await waitForVisibleAuthFields()
|
||||||
|
|
||||||
|
// forget to fill out confirm password
|
||||||
|
await page.locator('#field-email').fill(devUser.email)
|
||||||
|
await page.locator('#field-password').fill(devUser.password)
|
||||||
|
|
||||||
|
await page.locator('.form-submit > button').click()
|
||||||
|
await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText(
|
||||||
|
'This field is required.',
|
||||||
|
)
|
||||||
|
|
||||||
|
// make them match, but does not pass password validation
|
||||||
|
await page.locator('#field-email').fill(devUser.email)
|
||||||
|
await page.locator('#field-password').fill('12')
|
||||||
|
await page.locator('#field-confirm-password').fill('12')
|
||||||
|
|
||||||
|
await page.locator('.form-submit > button').click()
|
||||||
|
await expect(page.locator('.field-type.password .field-error')).toHaveText(
|
||||||
|
'This value must be longer than the minimum length of 3 characters.',
|
||||||
|
)
|
||||||
|
|
||||||
|
// should fill out all fields correctly
|
||||||
|
await page.locator('#field-email').fill(devUser.email)
|
||||||
|
await page.locator('#field-password').fill(devUser.password)
|
||||||
|
await page.locator('#field-confirm-password').fill(devUser.password)
|
||||||
|
await page.locator('#field-custom').fill('Hello, world!')
|
||||||
|
|
||||||
|
await page.locator('.form-submit > button').click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
|
||||||
|
.not.toContain('create-first-user')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('non create first user', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await reInitializeDB({
|
||||||
|
serverURL,
|
||||||
|
snapshotKey: 'auth',
|
||||||
|
deleteOnly: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
|
||||||
|
|
||||||
|
await login({ page, serverURL })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('passwords', () => {
|
describe('passwords', () => {
|
||||||
@@ -133,6 +144,15 @@ describe('Auth', () => {
|
|||||||
url = new AdminUrlUtil(serverURL, slug)
|
url = new AdminUrlUtil(serverURL, slug)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// reset password to original password
|
||||||
|
await page.goto(url.account)
|
||||||
|
await page.locator('#change-password').click()
|
||||||
|
await page.locator('#field-password').fill(devUser.password)
|
||||||
|
await page.locator('#field-confirm-password').fill(devUser.password)
|
||||||
|
await saveDocAndAssert(page, '#action-save')
|
||||||
|
})
|
||||||
|
|
||||||
test('should allow change password', async () => {
|
test('should allow change password', async () => {
|
||||||
await page.goto(url.account)
|
await page.goto(url.account)
|
||||||
const emailBeforeSave = await page.locator('#field-email').inputValue()
|
const emailBeforeSave = await page.locator('#field-email').inputValue()
|
||||||
@@ -196,6 +216,58 @@ describe('Auth', () => {
|
|||||||
await expect(page.locator('#users-api-result')).toHaveText('Goodbye, world!')
|
await expect(page.locator('#users-api-result')).toHaveText('Goodbye, world!')
|
||||||
await expect(page.locator('#use-auth-result')).toHaveText('Goodbye, world!')
|
await expect(page.locator('#use-auth-result')).toHaveText('Goodbye, world!')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Need to test unlocking documents on logout here as this test suite does not auto login users
|
||||||
|
test('should unlock document on logout after editing without saving', async () => {
|
||||||
|
await page.goto(url.list)
|
||||||
|
|
||||||
|
await page.locator('.table .row-1 .cell-custom a').click()
|
||||||
|
|
||||||
|
const textInput = page.locator('#field-namedSaveToJWT')
|
||||||
|
await expect(textInput).toBeVisible()
|
||||||
|
const docID = (await page.locator('.render-title').getAttribute('data-doc-id')) as string
|
||||||
|
|
||||||
|
const lockDocRequest = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.request().method() === 'POST' && response.request().url() === url.edit(docID),
|
||||||
|
)
|
||||||
|
await textInput.fill('some text')
|
||||||
|
await lockDocRequest
|
||||||
|
|
||||||
|
const lockedDocs = await payload.find({
|
||||||
|
collection: 'payload-locked-documents',
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => lockedDocs.docs.length).toBe(1)
|
||||||
|
|
||||||
|
await openNav(page)
|
||||||
|
|
||||||
|
await page.locator('.nav .nav__controls a[href="/admin/logout"]').click()
|
||||||
|
|
||||||
|
// Locate the modal container
|
||||||
|
const modalContainer = page.locator('.payload__modal-container')
|
||||||
|
await expect(modalContainer).toBeVisible()
|
||||||
|
|
||||||
|
// Click the "Leave anyway" button
|
||||||
|
await page
|
||||||
|
.locator('#leave-without-saving .confirmation-modal__controls .btn--style-primary')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
await expect(page.locator('.login')).toBeVisible()
|
||||||
|
|
||||||
|
const unlockedDocs = await payload.find({
|
||||||
|
collection: 'payload-locked-documents',
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => unlockedDocs.docs.length).toBe(0)
|
||||||
|
|
||||||
|
// added so tests after this do not need to re-login
|
||||||
|
await login({ page, serverURL })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('api-keys', () => {
|
describe('api-keys', () => {
|
||||||
@@ -263,4 +335,5 @@ describe('Auth', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -248,11 +248,13 @@ export interface User {
|
|||||||
hash?: string | null;
|
hash?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
sessions: {
|
sessions?:
|
||||||
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}[];
|
}[]
|
||||||
|
| null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -270,11 +272,13 @@ export interface PartialDisableLocalStrategy {
|
|||||||
hash?: string | null;
|
hash?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
sessions: {
|
sessions?:
|
||||||
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}[];
|
}[]
|
||||||
|
| null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -316,11 +320,13 @@ export interface PublicUser {
|
|||||||
_verificationToken?: string | null;
|
_verificationToken?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
sessions: {
|
sessions?:
|
||||||
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}[];
|
}[]
|
||||||
|
| null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
34
test/auth/seed.ts
Normal file
34
test/auth/seed.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Config } from 'payload'
|
||||||
|
|
||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
|
||||||
|
import { devUser } from '../credentials.js'
|
||||||
|
import { apiKeysSlug } from './shared.js'
|
||||||
|
|
||||||
|
export const seed: Config['onInit'] = async (payload) => {
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
custom: 'Hello, world!',
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: apiKeysSlug,
|
||||||
|
data: {
|
||||||
|
apiKey: uuid(),
|
||||||
|
enableAPIKey: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: apiKeysSlug,
|
||||||
|
data: {
|
||||||
|
apiKey: uuid(),
|
||||||
|
enableAPIKey: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -98,10 +98,26 @@ export async function ensureCompilationIsDone({
|
|||||||
|
|
||||||
await page.goto(adminURL)
|
await page.goto(adminURL)
|
||||||
|
|
||||||
await page.waitForURL(
|
if (readyURL) {
|
||||||
readyURL ??
|
await page.waitForURL(readyURL)
|
||||||
(noAutoLogin ? `${adminURL + (adminURL.endsWith('/') ? '' : '/')}login` : adminURL),
|
} else {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
if (noAutoLogin) {
|
||||||
|
const baseAdminURL = adminURL + (adminURL.endsWith('/') ? '' : '/')
|
||||||
|
return (
|
||||||
|
page.url() === `${baseAdminURL}create-first-user` ||
|
||||||
|
page.url() === `${baseAdminURL}login`
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
return page.url() === adminURL
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: POLL_TOPASS_TIMEOUT },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Successfully compiled')
|
console.log('Successfully compiled')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const handler: PayloadHandler = async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query: {
|
const query: {
|
||||||
deleteOnly?: boolean
|
deleteOnly?: string
|
||||||
snapshotKey?: string
|
snapshotKey?: string
|
||||||
uploadsDir?: string | string[]
|
uploadsDir?: string | string[]
|
||||||
} = qs.parse(req.url.split('?')[1] ?? '', {
|
} = qs.parse(req.url.split('?')[1] ?? '', {
|
||||||
@@ -31,7 +31,8 @@ const handler: PayloadHandler = async (req) => {
|
|||||||
snapshotKey: String(query.snapshotKey),
|
snapshotKey: String(query.snapshotKey),
|
||||||
// uploadsDir can be string or stringlist
|
// uploadsDir can be string or stringlist
|
||||||
uploadsDir: query.uploadsDir as string | string[],
|
uploadsDir: query.uploadsDir as string | string[],
|
||||||
deleteOnly: query.deleteOnly,
|
// query value will be a string of 'true' or 'false'
|
||||||
|
deleteOnly: query.deleteOnly === 'true',
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ export interface User {
|
|||||||
hash?: string | null;
|
hash?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
|
sessions?:
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -288,6 +295,13 @@ export interface UsersSelect<T extends boolean = true> {
|
|||||||
hash?: T;
|
hash?: T;
|
||||||
loginAttempts?: T;
|
loginAttempts?: T;
|
||||||
lockUntil?: T;
|
lockUntil?: T;
|
||||||
|
sessions?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
id?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
expiresAt?: T;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
|||||||
Reference in New Issue
Block a user