diff --git a/package.json b/package.json index 1b3bdd55c..8f525447e 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "uuid": "9.0.1", "webpack": "^4.35.3", "webpack-cli": "^3.3.0", - "webpack-dev-server": "^3.2.1" + "webpack-dev-server": "^3.2.1", + "imapflow": "^1.0.164" } } diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/profile/user-profile.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/profile/user-profile.tsx index ebd839224..bfa293703 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/profile/user-profile.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/profile/user-profile.tsx @@ -1,7 +1,7 @@ 'use client'; import { FC, ReactNode, useState } from 'react'; -import { Space, Card, Typography, App, Table, Alert } from 'antd'; +import { Space, Card, Typography, App, Table, Alert, Modal, Form, Input } from 'antd'; import styles from './user-profile.module.scss'; import { RightOutlined } from '@ant-design/icons'; import { signOut } from 'next-auth/react'; @@ -17,7 +17,11 @@ const UserProfile: FC<{ userData: User }> = ({ userData }) => { const [changeNameModalOpen, setChangeNameModalOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(undefined); - const { message: messageApi } = App.useApp(); + const [changeEmailModalOpen, setChangeEmailModalOpen] = useState(false); + const [errors, parseEmail] = useParseZodErrors(z.object({ email: z.string().email() })); + const [changeEmailForm] = Form.useForm(); + + const { message: messageApi, notification } = App.useApp(); async function deleteUser() { try { @@ -33,6 +37,26 @@ const UserProfile: FC<{ userData: User }> = ({ userData }) => { } } + async function requestEmailChange(values: unknown) { + try { + const data = parseEmail(values); + if (!data) return; + + const response = await serverRequestEmailChange(data.email); + if (response && 'error' in response) throw response; + + setChangeEmailModalOpen(false); + notification.success({ + message: 'Email change request successful', + description: 'Check your Email for the verification link', + }); + } catch (e: unknown) { + //@ts-ignore + const content = (e?.error?.message as ReactNode) ? e.error.message : 'An error ocurred'; + messageApi.error({ content }); + } + } + const firstName = userData.guest ? 'Guest' : userData.firstName || ''; const lastName = userData.guest ? '' : userData.lastName || ''; @@ -65,6 +89,31 @@ const UserProfile: FC<{ userData: User }> = ({ userData }) => { }} /> + setChangeEmailModalOpen(false)} + onOk={changeEmailForm.submit} + destroyOnClose + > + +
+ + + +
+
+ {errorMessage && ( diff --git a/src/management-system-v2/app/api/auth/[...nextauth]/adapter.ts b/src/management-system-v2/app/api/auth/[...nextauth]/adapter.ts index 295be62ff..d31b85de9 100644 --- a/src/management-system-v2/app/api/auth/[...nextauth]/adapter.ts +++ b/src/management-system-v2/app/api/auth/[...nextauth]/adapter.ts @@ -6,11 +6,14 @@ import { addOauthAccount, getOauthAccountByProviderId, } from '@/lib/data/legacy/iam/users'; +import { + createVerificationToken, + deleteVerificationToken, + getVerificationToken, +} from '@/lib/data/legacy/verification-tokens'; import { AuthenticatedUser } from '@/lib/data/user-schema'; import { type Adapter, AdapterAccount, VerificationToken } from 'next-auth/adapters'; -const invitationTokens = new Map(); - const Adapter = { createUser: async ( user: Omit | { email: string; emailVerified: Date }, @@ -25,21 +28,20 @@ const Adapter = { return getUserById(id); }, updateUser: async (user: AuthenticatedUser) => { - return updateUser(user.id, user); + return updateUser(user.id, { ...user, guest: false }); }, getUserByEmail: async (email: string) => { return getUserByEmail(email) ?? null; }, createVerificationToken: async (token: VerificationToken) => { - invitationTokens.set(token.identifier, token); - return token; + return createVerificationToken(token); }, - useVerificationToken: async ({ identifier }: { identifier: string; token: string }) => { + useVerificationToken: async (params: { identifier: string; token: string }) => { // next-auth checks if the token is expired - const storedToken = invitationTokens.get(identifier); - invitationTokens.delete(identifier); + const token = getVerificationToken(params); + if (token) deleteVerificationToken(params); - return storedToken ?? null; + return token ?? null; }, linkAccount: async (account: AdapterAccount) => { return addOauthAccount({ diff --git a/src/management-system-v2/app/api/auth/[...nextauth]/auth-options.ts b/src/management-system-v2/app/api/auth/[...nextauth]/auth-options.ts index bc76bfa99..badcd6fc4 100644 --- a/src/management-system-v2/app/api/auth/[...nextauth]/auth-options.ts +++ b/src/management-system-v2/app/api/auth/[...nextauth]/auth-options.ts @@ -35,7 +35,10 @@ const nextAuthOptions: AuthOptions = { }), EmailProvider({ sendVerificationRequest(params) { - const signinMail = renderSigninLinkEmail(params.url, params.expires); + const signinMail = renderSigninLinkEmail({ + signInLink: params.url, + expires: params.expires, + }); sendEmail({ to: params.identifier, @@ -63,16 +66,26 @@ const nextAuthOptions: AuthOptions = { return session; }, - signIn: async ({ account, user: _user }) => { + signIn: async ({ account, user: _user, email }) => { const session = await getServerSession(nextAuthOptions); const sessionUser = session?.user; - if (sessionUser?.guest && account?.provider !== 'guest-loguin') { + if ( + sessionUser?.guest && + account?.provider !== 'guest-signin' && + !email?.verificationRequest + ) { + // Check if the user's cookie is correct + const sessionUserInDb = getUserById(sessionUser.id); + if (!sessionUserInDb || !sessionUserInDb.guest) throw new Error('Something went wrong'); + const user = _user as Partial; - const guestUser = getUserById(sessionUser.id); + const userSigningIn = getUserById(_user.id); - if (guestUser.guest) { - updateUser(guestUser.id, { + if (userSigningIn) { + updateUser(sessionUser.id, { guest: true, signedInWithUserId: userSigningIn.id }); + } else { + updateUser(sessionUser.id, { firstName: user.firstName ?? undefined, lastName: user.lastName ?? undefined, username: user.username ?? undefined, @@ -221,6 +234,32 @@ if (process.env.NODE_ENV === 'development') { ); } +// add the test user in preview deployments and dev +// dev is for local testing +const url = process.env.NEXTAUTH_URL; +if ( + (url && url.endsWith('app.run') && url.startsWith('https://pr-')) || + process.env.NODE_ENV === 'development' +) { + nextAuthOptions.providers.push( + CredentialsProvider({ + id: 'test-user', + name: 'Continue With Test User', + credentials: {}, + async authorize() { + return addUser({ + guest: false, + email: `test-user-${crypto.randomUUID()}@proceed-labs.org`, + firstName: 'Test', + lastName: 'Test', + username: 'test-user', + emailVerified: new Date(), + }); + }, + }), + ); +} + export type ExtractedProvider = | { id: string; diff --git a/src/management-system-v2/app/change-email/change-email-card.tsx b/src/management-system-v2/app/change-email/change-email-card.tsx new file mode 100644 index 000000000..026ab7749 --- /dev/null +++ b/src/management-system-v2/app/change-email/change-email-card.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Space, Button, App, Card, Typography } from 'antd'; +import { useState } from 'react'; +import { changeEmail as serverChangeEmail } from '@/lib/change-email/server-actions'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ArrowRightOutlined } from '@ant-design/icons'; +import Content from '@/components/content'; +import { useSession } from 'next-auth/react'; + +export default function ChangeEmailCard({ + previousEmail, + newEmail, +}: { + previousEmail?: string; + newEmail: string; +}) { + const { message } = App.useApp(); + const params = useSearchParams(); + const router = useRouter(); + const session = useSession(); + + const [loading, setLoading] = useState<'changing' | 'cancelling' | undefined>(); + async function changeEmail(cancel: boolean = false) { + try { + setLoading(cancel ? 'cancelling' : 'changing'); + + const response = await serverChangeEmail(params.get('token')!, params.get('email')!, cancel); + + if (response?.error) throw response.error.message; + + if (cancel) { + message.open({ content: 'Email change cancelled', type: 'success' }); + } else { + message.open({ content: 'Email changed', type: 'success' }); + session.update(); + } + + router.push('/profile'); + } catch (e) { + const content = typeof e === 'string' ? e : 'An error occurred'; + + message.open({ content, type: 'error' }); + setLoading(undefined); + } + } + + return ( + + + {previousEmail ? ( + <> + {previousEmail} + + {newEmail} + + ) : ( + <> + Your email will now be {newEmail} + + )} +
+ + + + +
+
+ ); +} diff --git a/src/management-system-v2/app/change-email/page.tsx b/src/management-system-v2/app/change-email/page.tsx new file mode 100644 index 000000000..696a3f008 --- /dev/null +++ b/src/management-system-v2/app/change-email/page.tsx @@ -0,0 +1,34 @@ +import { getCurrentUser } from '@/components/auth'; +import { getTokenHash, notExpired } from '@/lib/change-email/utils'; +import { getVerificationToken } from '@/lib/data/legacy/verification-tokens'; +import { redirect } from 'next/navigation'; +import { z } from 'zod'; +import ChangeEmailCard from './change-email-card'; + +const searchParamsScema = z.object({ email: z.string().email(), token: z.string() }); + +export default async function ChangeEmailPage({ searchParams }: { searchParams: unknown }) { + const parsedSearchkParams = searchParamsScema.safeParse(searchParams); + if (!parsedSearchkParams.success) redirect('/'); + const { email, token } = parsedSearchkParams.data; + + const { session } = await getCurrentUser(); + const userId = session?.user.id; + if (!userId || session.user.guest) redirect('/'); + const previousEmail = session.user.email; + + const verificationToken = getVerificationToken({ + identifier: email, + token: await getTokenHash(token), + }); + + if ( + !verificationToken || + !verificationToken.updateEmail || + verificationToken.userId !== userId || + !(await notExpired(verificationToken)) + ) + redirect('/'); + + return ; +} diff --git a/src/management-system-v2/app/transfer-processes/page.tsx b/src/management-system-v2/app/transfer-processes/page.tsx new file mode 100644 index 000000000..364cb17e8 --- /dev/null +++ b/src/management-system-v2/app/transfer-processes/page.tsx @@ -0,0 +1,54 @@ +import { getCurrentUser } from '@/components/auth'; +import Content from '@/components/content'; +import { getProcesses } from '@/lib/data/legacy/_process'; +import { getUserById } from '@/lib/data/legacy/iam/users'; +import { Card } from 'antd'; +import { redirect } from 'next/navigation'; +import TransferProcessesConfirmationButtons from './transfer-processes-confitmation-buttons'; + +export default async function TransferProcessesPage({ + searchParams, +}: { + searchParams: { + callbackUrl?: string; + guestId?: string; + }; +}) { + const { userId, session } = await getCurrentUser(); + if (!session) redirect('api/auth/signin'); + if (session.user.guest) redirect('/'); + + const callbackUrl = searchParams.callbackUrl || '/'; + + const guestId = searchParams.guestId; + // guestId === userId if the user signed in with a non existing account, and the guest user was + // turned into an authenticated user + if (!guestId || guestId === userId) redirect(callbackUrl); + + const possibleGuest = getUserById(guestId); + // possibleGuest might be a normal user, this would happen if the user signed in with a new + // account, we only go further then this redirect, if the user signed in with an account that was + // already linked to an existing user + if (!possibleGuest || !possibleGuest.guest || possibleGuest?.signedInWithUserId !== userId) + redirect(callbackUrl); + + // NOTE: this ignores folders + const guestProcesses = (await getProcesses()).filter( + (process) => process.environmentId === guestId, + ); + if (guestProcesses.length === 0) redirect(callbackUrl); + + return ( + + + Your guest account had {guestProcesses.length} process{guestProcesses.length !== 1 && 'es'}. +
+ Would you like to transfer them to your account? + +
+
+ ); +} diff --git a/src/management-system-v2/app/transfer-processes/server-actions.ts b/src/management-system-v2/app/transfer-processes/server-actions.ts new file mode 100644 index 000000000..f9a61a086 --- /dev/null +++ b/src/management-system-v2/app/transfer-processes/server-actions.ts @@ -0,0 +1,89 @@ +'use server'; + +import { getCurrentUser } from '@/components/auth'; +import { Folder } from '@/lib/data/folder-schema'; +import { getProcesses, removeProcess, updateProcess } from '@/lib/data/legacy/_process'; +import { + getFolders, + getRootFolder, + moveFolder, + updateFolderMetaData, +} from '@/lib/data/legacy/folders'; +import { deleteEnvironment } from '@/lib/data/legacy/iam/environments'; +import { deleteUser, getUserById } from '@/lib/data/legacy/iam/users'; +import { Process } from '@/lib/data/process-schema'; +import { UserErrorType, userError } from '@/lib/user-error'; +import { redirect } from 'next/navigation'; + +async function ensureValidRequest(guestId: string) {} + +export async function transferProcesses(guestId: string, callbackUrl: string = '/') { + const { session } = await getCurrentUser(); + if (!session) return userError("You're not signed in", UserErrorType.PermissionError); + if (session.user.guest) + return userError("You can't be a guest to transfer processes", UserErrorType.PermissionError); + + if (guestId === session.user.id) redirect(callbackUrl); + + const possibleGuest = getUserById(guestId); + if ( + !possibleGuest || + !possibleGuest.guest || + possibleGuest?.signedInWithUserId !== session.user.id + ) + return userError('Invalid guest id', UserErrorType.PermissionError); + + // Processes and folders under root folder of guest space guet their folderId changed to the + // root folder of the new owner space, for the rest we just update the environmentId + const userRootFolderId = getRootFolder(session.user.id).id; + const guestRootFolderId = getRootFolder(guestId).id; + + const guestProcesses = (await getProcesses()).filter( + ({ environmentId }) => environmentId === guestId, + ); + for (const process of guestProcesses) { + const processUpdate: Partial = { + environmentId: session.user.id, + owner: session.user.id, + }; + if (process.folderId === guestRootFolderId) processUpdate.folderId = userRootFolderId; + updateProcess(process.id, processUpdate); + } + + const guestFolders = getFolders(guestId); + for (const folder of guestFolders) { + if (folder.id === guestRootFolderId) continue; + + const folderData: Partial = { createdBy: session.user.id }; + + if (folder.parentId === guestRootFolderId) moveFolder(folder.id, userRootFolderId); + else folderData.environmentId = session.user.id; + + updateFolderMetaData(folder.id, folderData); + } + + deleteUser(guestId); + + redirect(callbackUrl); +} + +export async function discardProcesses(guestId: string, redirectUrl: string = '/') { + const { session } = await getCurrentUser(); + if (!session) return userError("You're not signed in", UserErrorType.PermissionError); + if (session.user.guest) + return userError("You can't be a guest to transfer processes", UserErrorType.PermissionError); + + if (guestId === session.user.id) redirect(redirectUrl); + + const possibleGuest = getUserById(guestId); + if ( + !possibleGuest || + !possibleGuest.guest || + possibleGuest?.signedInWithUserId !== session.user.id + ) + return userError('Invalid guest id', UserErrorType.PermissionError); + + deleteUser(guestId); + + redirect(redirectUrl); +} diff --git a/src/management-system-v2/app/transfer-processes/transfer-processes-confitmation-buttons.tsx b/src/management-system-v2/app/transfer-processes/transfer-processes-confitmation-buttons.tsx new file mode 100644 index 000000000..14943af29 --- /dev/null +++ b/src/management-system-v2/app/transfer-processes/transfer-processes-confitmation-buttons.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { Space, Button } from 'antd'; +import { useTransition } from 'react'; +import { + transferProcesses as serverTransferProcesses, + discardProcesses as serverDiscardProcesses, +} from './server-actions'; + +export default function TransferProcessesConfirmationButtons({ + guestId, + callbackUrl, +}: { + guestId: string; + callbackUrl?: string; +}) { + const [transferring, startTransfer] = useTransition(); + function transferProcesses() { + startTransfer(async () => { + await serverTransferProcesses(guestId, callbackUrl); + }); + } + + const [discardingProcesses, startDiscardingProcesses] = useTransition(); + function discardProcesses() { + startDiscardingProcesses(async () => { + await serverDiscardProcesses(guestId, callbackUrl); + }); + } + + return ( + + + + + ); +} diff --git a/src/management-system-v2/lib/change-email/server-actions.ts b/src/management-system-v2/lib/change-email/server-actions.ts new file mode 100644 index 000000000..2449cd20c --- /dev/null +++ b/src/management-system-v2/lib/change-email/server-actions.ts @@ -0,0 +1,73 @@ +'use server'; + +import { z } from 'zod'; +import { userError } from '../user-error'; +import { createChangeEmailVerificationToken, getTokenHash, notExpired } from './utils'; +import { getCurrentUser } from '@/components/auth'; +import { + createVerificationToken, + getVerificationToken, + deleteVerificationToken, +} from '@/lib/data/legacy/verification-tokens'; +import { updateUser } from '@/lib/data/legacy/iam/users'; +import { sendEmail } from '../email/mailer'; +import renderSigninLinkEmail from '../email/signin-link-email'; + +export async function requestEmailChange(newEmail: string) { + try { + const { session } = await getCurrentUser(); + if (!session || session.user.guest) + return userError('You must be signed in to change your email'); + const userId = session.user.id; + + const email = z.string().email().parse(newEmail); + + const { verificationToken, redirectUrl } = await createChangeEmailVerificationToken({ + email, + userId, + }); + + createVerificationToken(verificationToken); + + const signinMail = renderSigninLinkEmail({ + signInLink: redirectUrl, + expires: verificationToken.expires, + headerText: 'Change your email address', + description: + 'Hi, you have requested to change the email address associated with your PROCEED account. Please click the link below to confirm this change:', + footerText: + 'If you did not request this email change, you can ignore this email. Your account remains secure and can only be accessed with your original email address. The PROCEED Crew', + }); + + sendEmail({ + to: email, + subject: 'PROCEED: Change your email address', + html: signinMail.html, + text: signinMail.text, + }); + } catch (e) { + if (e instanceof z.ZodError) return userError('Invalid email'); + + return userError('Something went wrong'); + } +} + +export async function changeEmail(token: string, identifier: string, cancel: boolean = false) { + const { session, userId } = await getCurrentUser(); + if (!session || session.user.guest) + return userError('You must be signed in to change your email'); + + const tokenParams = { identifier, token: await getTokenHash(token) }; + const verificationToken = getVerificationToken(tokenParams); + if ( + !verificationToken || + !verificationToken.updateEmail || + verificationToken.userId !== userId || + !(await notExpired(verificationToken)) + ) + return userError('Invalid token'); + + if (!cancel) updateUser(userId, { email: verificationToken.identifier, guest: false }); + + deleteVerificationToken(tokenParams); +} diff --git a/src/management-system-v2/lib/change-email/utils.ts b/src/management-system-v2/lib/change-email/utils.ts new file mode 100644 index 000000000..6b6eae9ae --- /dev/null +++ b/src/management-system-v2/lib/change-email/utils.ts @@ -0,0 +1,57 @@ +import 'server-only'; + +import nextAuthOptions from '@/app/api/auth/[...nextauth]/auth-options'; +import { z } from 'zod'; +import { VerificationToken } from '../data/legacy/verification-tokens'; + +async function createHash(message: string) { + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; +} + +export function getTokenHash(token: string) { + return createHash(`${token}${nextAuthOptions.secret}`); +} + +export async function createChangeEmailVerificationToken({ + email, + userId, +}: { + email: string; + userId: string; +}) { + const identifier = z.string().email().parse(email); + + const token = crypto.randomUUID(); + const expires = new Date(Date.now() + 1000 * 60 * 60 * 24); + + const verificationToken = { + token: await getTokenHash(token), + expires, + identifier, + updateEmail: true, + userId, + } satisfies VerificationToken; + + const redirectUrl = new URL( + '/change-email?' + + new URLSearchParams({ + token, + email: identifier, + }), + process.env.NEXTAUTH_URL ?? 'http://localhost:3000', + ).toString(); + + return { verificationToken, redirectUrl }; +} + +export async function notExpired( + verificationToken: Extract, +) { + if (verificationToken.expires.valueOf() < Date.now()) return false; + return true; +} diff --git a/src/management-system-v2/lib/data/legacy/_process.ts b/src/management-system-v2/lib/data/legacy/_process.ts index 4437f64bc..e5da52536 100644 --- a/src/management-system-v2/lib/data/legacy/_process.ts +++ b/src/management-system-v2/lib/data/legacy/_process.ts @@ -49,18 +49,16 @@ export function getProcessMetaObjects() { } /** Returns all processes for a user */ -export async function getProcesses(ability: Ability, includeBPMN = false) { +export async function getProcesses(ability?: Ability, includeBPMN = false) { const processes = Object.values(processMetaObjects); - const userProcesses = await Promise.all( - ability - .filter('view', 'Process', processes) - .map(async (process) => - !includeBPMN ? process : { ...process, bpmn: getProcessBpmn(process.id) }, - ), - ); + const userProcesses = ability ? ability.filter('view', 'Process', processes) : processes; - return userProcesses; + return await Promise.all( + userProcesses.map(async (process) => + !includeBPMN ? process : { ...process, bpmn: getProcessBpmn(process.id) }, + ), + ); } export async function getProcess(processDefinitionsId: string, includeBPMN = false) { @@ -204,7 +202,7 @@ export function moveProcess({ if (!dontUpdateOldFolder) { const oldFolder = foldersMetaObject.folders[process.folderId]; - if (!oldFolder) throw new Error("Consistensy Error: Process' folder not found"); + if (!oldFolder) throw new Error("Consistency Error: Process' folder not found"); const processOldFolderIdx = oldFolder.children.findIndex( (item) => 'type' in item && item.type === 'process' && item.id === processDefinitionsId, ); @@ -301,7 +299,7 @@ export async function addProcessVersion(processDefinitionsId: string, bpmn: stri await saveProcessVersion(processDefinitionsId, versionInformation.version || 0, bpmn); - // add information about the new version to the meta information and inform others about its existance + // add information about the new version to the meta information and inform others about its existence const newVersions = existingProcess.versions ? [...existingProcess.versions] : []; //@ts-ignore diff --git a/src/management-system-v2/lib/data/legacy/folders.ts b/src/management-system-v2/lib/data/legacy/folders.ts index 9ac3845b0..d53a987d4 100644 --- a/src/management-system-v2/lib/data/legacy/folders.ts +++ b/src/management-system-v2/lib/data/legacy/folders.ts @@ -110,6 +110,21 @@ export function getFolderById(folderId: string, ability?: Ability) { return folderData.folder; } +export function getFolders(environmentId?: string, ability?: Ability) { + const _folders = environmentId + ? Object.values(foldersMetaObject.folders).filter( + (folder) => folder?.folder.environmentId === environmentId, + ) + : Object.values(foldersMetaObject.folders); + + const folders = _folders.map((f) => f!.folder); + + if (ability) + return folders.filter((folder) => ability.can('view', toCaslResource('Folder', folder))); + + return folders; +} + export function getFolderChildren(folderId: string, ability?: Ability) { const folderData = foldersMetaObject.folders[folderId]; if (!folderData) throw new Error('Folder not found'); @@ -235,7 +250,7 @@ function _deleteFolder( export function updateFolderMetaData( folderId: string, - newMetaDataInput: Partial, + newMetaDataInput: Partial>, ability?: Ability, ) { const folderData = foldersMetaObject.folders[folderId]; @@ -244,12 +259,15 @@ export function updateFolderMetaData( if (ability && !ability.can('update', toCaslResource('Folder', folderData.folder))) throw new Error('Permission denied'); - const newMetaData = FolderUserInputSchema.partial().parse(newMetaDataInput); - if ( - newMetaDataInput.environmentId && - newMetaDataInput.environmentId != folderData.folder.environmentId - ) - throw new Error('environmentId cannot be changed'); + const newMetaData = FolderSchema.omit({ + parentId: true, + id: true, + // if there is an ability, we interpret this as a user updating the folder + environmentId: ability ? true : undefined, + createdBy: ability ? true : undefined, + }) + .partial() + .parse(newMetaDataInput); const newFolder: Folder = { ...folderData.folder, @@ -287,7 +305,8 @@ export function moveFolder(folderId: string, newParentId: string, ability?: Abil const newParentData = foldersMetaObject.folders[newParentId]; if (!newParentData) throw new Error('New parent folder not found'); - if (newParentData.folder.environmentId !== folderData.folder.environmentId) + // only perform this check when an ability is present (it means that a user is moving the folder) + if (ability && newParentData.folder.environmentId !== folderData.folder.environmentId) throw new Error('Cannot move folder to a different environment'); const oldParentData = foldersMetaObject.folders[folderData.folder.parentId]; @@ -317,6 +336,7 @@ export function moveFolder(folderId: string, newParentId: string, ability?: Abil store.update('folders', oldParentData.folder.id, oldParentData.folder); folderData.folder.parentId = newParentId; + folderData.folder.environmentId = newParentData.folder.environmentId; newParentData.children.push({ type: 'folder', id: folderData.folder.id }); newParentData.folder.lastEdited = new Date().toISOString(); store.update('folders', newParentData.folder.id, newParentData.folder); diff --git a/src/management-system-v2/lib/data/legacy/iam/environments.ts b/src/management-system-v2/lib/data/legacy/iam/environments.ts index 49ad92db8..46d4d22f3 100644 --- a/src/management-system-v2/lib/data/legacy/iam/environments.ts +++ b/src/management-system-v2/lib/data/legacy/iam/environments.ts @@ -6,8 +6,7 @@ import { adminPermissions } from '@/lib/authorization/permissionHelpers'; import { addRoleMappings } from './role-mappings'; import { addMember, membershipMetaObject, removeMember } from './memberships'; import { Environment, EnvironmentInput, environmentSchema } from '../../environment-schema'; -import { getProcessMetaObjects, removeProcess } from '../_process'; -import { createFolder } from '../folders'; +import { createFolder, deleteFolder, getRootFolder } from '../folders'; // @ts-ignore let firstInit = !global.environmentMetaObject; @@ -118,21 +117,20 @@ export function deleteEnvironment(environmentId: string, ability?: Ability) { if (ability && !ability.can('delete', 'Environment')) throw new UnauthorizedError(); - const roles = Object.values(roleMetaObjects); - for (const role of roles) { - if (role.environmentId === environmentId) { - deleteRole(role.id); // also deletes role mappings - } - } + // NOTE: when using a db I think it would be faster to just delete processes and folders where de + // environmentId matches + const rootFolder = getRootFolder(environmentId); + if (!rootFolder) throw new Error('Root folder not found'); + deleteFolder(rootFolder.id); - const processes = Object.values(getProcessMetaObjects()); - for (const process of processes) { - if (process.environmentId === environmentId) { - removeProcess(process.id); + if (environment.organization) { + const roles = Object.values(roleMetaObjects); + for (const role of roles) { + if (role.environmentId === environmentId) { + deleteRole(role.id); // also deletes role mappings + } } - } - if (environment.organization) { const environmentMemberships = membershipMetaObject[environmentId]; if (environmentMemberships) { for (const { userId } of environmentMemberships) { diff --git a/src/management-system-v2/lib/data/legacy/iam/users.ts b/src/management-system-v2/lib/data/legacy/iam/users.ts index 547054bc7..71025ca09 100644 --- a/src/management-system-v2/lib/data/legacy/iam/users.ts +++ b/src/management-system-v2/lib/data/legacy/iam/users.ts @@ -7,6 +7,8 @@ import { OauthAccount, AuthenticatedUser, AuthenticatedUserSchema, + GuestUser, + GuestUserSchema, } from '../../user-schema'; import { addEnvironment, deleteEnvironment } from './environments'; import { OptionalKeys } from '@/lib/typescript-utils.js'; @@ -133,7 +135,12 @@ export function deleteUser(userId: string) { return user; } -export function updateUser(userId: string, inputUser: Partial) { +export function updateUser( + userId: string, + inputUser: + | (Partial & { guest: false }) + | (Partial & { guest: true }), +) { const user = getUserById(userId, { throwIfNotFound: true }); const isGoingToBeGuest = inputUser.guest !== undefined ? inputUser.guest : user.guest; @@ -142,7 +149,8 @@ export function updateUser(userId: string, inputUser: Partial if (isGoingToBeGuest) { updatedUser = { id: user.id, - guest: true, + signedInWithUserId: user.guest ? user.signedInWithUserId : undefined, + ...GuestUserSchema.parse(inputUser), }; } else { const newUserData = AuthenticatedUserSchema.partial().parse(inputUser); diff --git a/src/management-system-v2/lib/data/legacy/store.js b/src/management-system-v2/lib/data/legacy/store.js index 40dc4a464..2ba7a944e 100644 --- a/src/management-system-v2/lib/data/legacy/store.js +++ b/src/management-system-v2/lib/data/legacy/store.js @@ -54,6 +54,7 @@ if (!global.stores) { stores.folders = { store: getStore('folders') }; stores.machineConfig = { store: getStore('machineConfig') }; stores.systemAdmins = { store: getStore('systemAdmins') }; + stores.verificationTokens = { store: getStore('verificationTokens') }; } /** diff --git a/src/management-system-v2/lib/data/legacy/verification-tokens.ts b/src/management-system-v2/lib/data/legacy/verification-tokens.ts new file mode 100644 index 000000000..24306ad22 --- /dev/null +++ b/src/management-system-v2/lib/data/legacy/verification-tokens.ts @@ -0,0 +1,68 @@ +import store from './store.js'; +import { z } from 'zod'; + +const verificationTokenSchema = z.union([ + z.object({ + token: z.string(), + identifier: z.string(), + expires: z.date(), + updateEmail: z.literal(false).optional(), + }), + z.object({ + token: z.string(), + identifier: z.string(), + expires: z.date(), + updateEmail: z.literal(true), + userId: z.string(), + }), +]); + +export type VerificationToken = z.infer; + +// @ts-ignore +let firstInit = !global.verificationTokensMetaObject; + +export let verificationTokensMetaObject: Record = + // @ts-ignore + global.verificationTokensMetaObject || (global.verificationTokensMetaObject = {}); + +/** initializes the folders meta information objects */ +export function init() { + if (!firstInit) return; + + const storedTokens = store.get('verificationTokens') as (VerificationToken & { id: string })[]; + + for (const token of storedTokens) verificationTokensMetaObject[token.token] = token; +} + +init(); + +export function getVerificationToken(params: Pick) { + const token = verificationTokensMetaObject[params.token]; + if (!token || token.identifier !== params.identifier) return; + + return token as VerificationToken; +} + +export function deleteVerificationToken(params: Pick) { + const token = verificationTokensMetaObject[params.token]; + if (!token || token.identifier !== params.identifier) throw new Error('Token not found'); + + store.remove('verificationTokens', token.token); + delete verificationTokensMetaObject[token.token]; +} + +export function createVerificationToken(tokenInput: VerificationToken) { + const token = verificationTokenSchema.parse(tokenInput); + + if (verificationTokensMetaObject[token.token]) { + throw new Error('Token already exists'); + } + + const storeToken = { ...token, id: token.token }; // id because the store needs an id + + verificationTokensMetaObject[token.token] = storeToken; + store.add('verificationTokens', storeToken); + + return token; +} diff --git a/src/management-system-v2/lib/data/user-schema.ts b/src/management-system-v2/lib/data/user-schema.ts index 45b923cf8..3983e4156 100644 --- a/src/management-system-v2/lib/data/user-schema.ts +++ b/src/management-system-v2/lib/data/user-schema.ts @@ -39,6 +39,7 @@ export type AuthenticatedUser = z.infer & { id: export const GuestUserSchema = z.object({ guest: z.literal(true), id: z.string().optional(), + signedInWithUserId: z.string().optional(), }); export type GuestUser = z.infer & { id: string }; diff --git a/src/management-system-v2/lib/data/users.tsx b/src/management-system-v2/lib/data/users.tsx index e8f7cee1e..b61922212 100644 --- a/src/management-system-v2/lib/data/users.tsx +++ b/src/management-system-v2/lib/data/users.tsx @@ -58,7 +58,7 @@ export async function updateUser(newUserDataInput: AuthenticatedUserData) { const newUserData = AuthenticatedUserDataSchema.parse(newUserDataInput); - _updateUser(userId, newUserData); + _updateUser(userId, { ...newUserData, guest: false }); } catch (_) { return userError('Error updating user'); } diff --git a/src/management-system-v2/app/api/auth/[...nextauth]/signin-link-email.tsx b/src/management-system-v2/lib/email/signin-link-email.tsx similarity index 83% rename from src/management-system-v2/app/api/auth/[...nextauth]/signin-link-email.tsx rename to src/management-system-v2/lib/email/signin-link-email.tsx index ee9be1a07..42203011d 100644 --- a/src/management-system-v2/app/api/auth/[...nextauth]/signin-link-email.tsx +++ b/src/management-system-v2/lib/email/signin-link-email.tsx @@ -18,8 +18,17 @@ import * as React from 'react'; const baseUrl = process.env.NEXTAUTH_URL ?? ''; -function SigninUrlMail({ signInLink, expires }: { signInLink: string; expires: Date }) { - const expiresIn = expires.getTime() - Date.now(); +type MailProps = { + signInLink: string; + expires: Date; + linkText?: string; + headerText?: string; + description?: string; + footerText?: string; +}; + +function SigninUrlMail(mailProps: MailProps) { + const expiresIn = mailProps.expires.getTime() - Date.now(); const linkDuration: number = Math.floor(expiresIn / 1000 / 60 / 60); return ( @@ -66,11 +75,12 @@ function SigninUrlMail({ signInLink, expires }: { signInLink: string; expires: D marginBottom: '15px', }} > - Sign in to PROCEED + {mailProps.headerText ?? 'Sign in to PROCEED'} - Hi, with this mail you can sign in to your PROCEED account. If you don't have - an account yet, a new one will be created for you. Just click on the following link: + {mailProps.description ?? + `Hi, with this mail you can sign in to your PROCEED account. If you don't have + an account yet, a new one will be created for you. Just click on the following link:`} - Sign in Link + {mailProps.linkText ?? 'Sign in Link'}
- If you have not initiated the sign in, you can simply ignore this mail. Your account - is still secure as you can only sign in by email. The PROCEED Crew + {mailProps.footerText ?? + `If you have not initiated the sign in, you can simply ignore this mail. Your account + is still secure as you can only sign in by email. The PROCEED Crew`}
@@ -164,8 +175,8 @@ const text = { margin: '24px 0', }; -export default function renderSigninLinkEmail(signInLink: string, expires: Date) { - const email = ; +export default function renderSigninLinkEmail(mailProps: MailProps) { + const email = ; return { html: render(email), text: render(email, { plainText: true }) }; } diff --git a/tests/ms2/authentication/authtenticatin-flows.spec.ts b/tests/ms2/authentication/authtenticatin-flows.spec.ts new file mode 100644 index 000000000..a023fd589 --- /dev/null +++ b/tests/ms2/authentication/authtenticatin-flows.spec.ts @@ -0,0 +1,133 @@ +import { Locator, mergeExpects } from '@playwright/test'; +import { closeModal, getSigninLink, openModal, waitForHydration } from '../testUtils'; +import { expect, test } from './signin.fitxtures'; + +test('Transfer processes from guest', async ({ signinPage, ms2Page, processListPage }) => { + const page = signinPage.page; + + // Sign in as guest + await ms2Page.login(); + + // Create guest assets + const process1 = await processListPage.createProcess({ + processName: 'guest: process 1', + returnToProcessList: true, + }); + const process2 = await processListPage.createProcess({ + processName: 'guest: process 2', + returnToProcessList: true, + }); + + const folderName = crypto.randomUUID(); + await processListPage.createFolder({ folderName }); + const folderLocator = processListPage.folderLocatorByName(folderName); + await folderLocator.click(); + await waitForHydration(page); + + const process3 = await processListPage.createProcess({ processName: 'guest: process 3' }); + await processListPage.goto(); + await folderLocator.click(); + await waitForHydration(page); + await expect(processListPage.processLocatorByDefinitionId(process3)).toBeVisible(); + + // Sign in to an authenticated user + await signinPage.signinAsTestUser(); + + // Transfer processes + await page.waitForURL('**/transfer-processes*'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Yes' }).click(); + // wait for redirect + await page.waitForURL(/^(?!.*trasnfer-processes).*$/); + + // Verify that the guest assets where transferred + await processListPage.goto(); + + await expect(processListPage.processLocatorByDefinitionId(process1)).toBeVisible(); + await expect(processListPage.processLocatorByDefinitionId(process2)).toBeVisible(); + await expect(folderLocator).toBeVisible(); + await folderLocator.click(); + await waitForHydration(page); + await expect(processListPage.processLocatorByDefinitionId(process3)).toBeVisible(); +}); + +test("Don't Transfer processes from guest", async ({ signinPage, ms2Page, processListPage }) => { + const page = signinPage.page; + + // Sign in as guest + await ms2Page.login(); + + // Create guest assets + const process1 = await processListPage.createProcess({ + processName: 'guest: process 1', + returnToProcessList: true, + }); + const process2 = await processListPage.createProcess({ + processName: 'guest: process 2', + returnToProcessList: true, + }); + + const folderName = crypto.randomUUID(); + await processListPage.createFolder({ folderName }); + const folderLocator = processListPage.folderLocatorByName(folderName); + await folderLocator.click(); + await waitForHydration(page); + + const process3 = await processListPage.createProcess({ processName: 'guest: process 3' }); + await processListPage.goto(); + await folderLocator.click(); + await waitForHydration(page); + await expect(processListPage.processLocatorByDefinitionId(process3)).toBeVisible(); + + // Sign in to an authenticated user + await signinPage.signinAsTestUser(); + + // Transfer processes + await page.waitForURL('**/transfer-processes*'); + await waitForHydration(page); + await page.getByRole('button', { name: 'No' }).click(); + // wait for redirect + await page.waitForURL(/^(?!.*trasnfer-processes).*$/); + + // Verify that the guest assets where transferred + await processListPage.goto(); + + await expect(processListPage.processLocatorByDefinitionId(process1)).not.toBeVisible(); + await expect(processListPage.processLocatorByDefinitionId(process2)).not.toBeVisible(); + await expect(folderLocator).not.toBeVisible(); +}); + +test('Sign in with E-mail', async ({ signinPage }) => { + const page = signinPage.page; + + await openModal(page, () => page.goto('/signin')); + + const sentDate = new Date(); + await page.getByPlaceholder('E-Mail').fill(process.env.TEST_EMAIL); + await page.getByRole('button', { name: 'Continue with E-Mail' }).click(); + + const signInLink = await getSigninLink(sentDate); + + // It could be the case that the email already had a profile + page.goto(signInLink); + + await new Promise((res) => setTimeout(res, 7000)); + const profileModal = page + .locator(`div[aria-modal="true"]:not(.ant-zoom)`) + .getByText('You need to complete your profile') + .first(); + const newUser = await profileModal.isVisible(); + + if (newUser) { + await page.getByLabel('First Name').fill('Test'); + await page.getByLabel('Last Name').fill('User'); + await page.getByLabel('Username').fill(`${crypto.randomUUID().slice(0, 35)}`); + + await closeModal(profileModal, () => page.getByRole('button', { name: 'Submit' }).click()); + } + + await page.goto('/profile'); + await waitForHydration(page); + + await expect(page.getByRole('cell', { name: process.env.TEST_EMAIL })).toBeVisible(); +}); diff --git a/tests/ms2/authentication/signin.fitxtures.ts b/tests/ms2/authentication/signin.fitxtures.ts new file mode 100644 index 000000000..ad71604eb --- /dev/null +++ b/tests/ms2/authentication/signin.fitxtures.ts @@ -0,0 +1,29 @@ +import { test as base } from '@playwright/test'; +import { SigninPage } from './signin.page'; +import { MS2Page } from '../ms2.page'; +import { ProcessListPage } from '../processes/process-list.page'; + +export const test = base.extend<{ + signinPage: SigninPage; + ms2Page: MS2Page; + processListPage: ProcessListPage; +}>({ + ms2Page: async ({ page }, use) => { + const ms2Page = new MS2Page(page); + await use(ms2Page); + }, + processListPage: async ({ page, ms2Page }, use) => { + const processListPage = new ProcessListPage(page); + await use(processListPage); + await processListPage.removeAllProcesses(); + }, + signinPage: async ({ page, processListPage }, use) => { + const signinPage = new SigninPage(page); + await use(signinPage); + + // TODO: move this somewhere else + avoid race conditions + await signinPage.deleteTestUser(); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/ms2/authentication/signin.page.ts b/tests/ms2/authentication/signin.page.ts new file mode 100644 index 000000000..35e4144af --- /dev/null +++ b/tests/ms2/authentication/signin.page.ts @@ -0,0 +1,32 @@ +import { Page } from '@playwright/test'; +import { openModal, waitForHydration } from '../testUtils'; + +export class SigninPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async goto() { + await openModal(this.page, () => this.page.goto('/signin')); + } + + async signinAsGuest() {} + + async signinAsTestUser() { + await this.goto(); + await this.page.getByRole('button', { name: 'Continue With Test User' }).click(); + } + + async deleteTestUser() { + await this.page.goto('/profile'); + await waitForHydration(this.page); + await this.page.getByRole('button', { name: 'Delete Account' }).click(); + await this.page + .getByLabel('Delete Account') + .getByRole('button', { name: 'Delete Account' }) + .click(); + await this.page.waitForURL('**/signin*'); + } +} diff --git a/tests/ms2/processes/process-list.page.ts b/tests/ms2/processes/process-list.page.ts index c6ab281ac..8f9617a08 100644 --- a/tests/ms2/processes/process-list.page.ts +++ b/tests/ms2/processes/process-list.page.ts @@ -104,12 +104,15 @@ export class ProcessListPage { return this.processDefinitionIds; } + processLocatorByDefinitionId(definitionId: string) { + return this.page.locator(`tr[data-row-key="${definitionId}"]`); + } + async removeProcess(definitionId: string) { const { page } = this; const modal = await openModal(page, () => - page - .locator(`tr[data-row-key="${definitionId}"]`) + this.processLocatorByDefinitionId(definitionId) .getByRole('button', { name: 'delete' }) .click(), ); @@ -142,7 +145,7 @@ export class ProcessListPage { .fill(processName ?? 'My Process'); await modal.getByLabel('Process Description').fill(description ?? 'Process Description'); await modal.getByRole('button', { name: 'Create' }).click(); - await page.waitForURL(/processes\/([a-zA-Z0-9-_]+)/); + await page.waitForURL(/processes\/(?!folder)([a-zA-Z0-9-_]+)/); const id = page.url().split('processes/').pop(); @@ -185,6 +188,10 @@ export class ProcessListPage { } } + folderLocatorByName(folderName: string) { + return this.page.locator(`tr:has(span:text-is("${folderName}"))`); + } + async createFolder({ folderName, folderDescription, @@ -204,7 +211,7 @@ export class ProcessListPage { if (folderDescription) await modal.getByLabel('Description').fill(folderDescription); await closeModal(modal, () => page.getByRole('button', { name: 'OK' }).click()); // NOTE: this could break if there is another folder with the same name - const folderRow = page.locator(`tr:has(span:text-is("${folderName}"))`); + const folderRow = this.folderLocatorByName(folderName); await expect(folderRow).toBeVisible(); return folderRow.getAttribute('data-row-key'); diff --git a/tests/ms2/processes/process-list.spec.ts b/tests/ms2/processes/process-list.spec.ts index 62bc02a94..31ac9ccff 100644 --- a/tests/ms2/processes/process-list.spec.ts +++ b/tests/ms2/processes/process-list.spec.ts @@ -11,10 +11,11 @@ test('create a new process and remove it again', async ({ processListPage }) => await processListPage.goto(); - await expect(page.locator(`tr[data-row-key="${processDefinitionID}"]`)).toBeVisible(); + const processLocator = processListPage.processLocatorByDefinitionId(processDefinitionID); + await expect(processLocator).toBeVisible(); const modal = await openModal(page, () => - page + processLocator .locator(`tr[data-row-key="${processDefinitionID}"]`) .getByRole('button', { name: 'delete' }) .click(), @@ -22,7 +23,7 @@ test('create a new process and remove it again', async ({ processListPage }) => await closeModal(modal, () => modal.getByRole('button', { name: 'OK' }).click()); - await expect(page.locator(`tr[data-row-key="${processDefinitionID}"]`)).not.toBeVisible(); + await expect(processLocator).not.toBeVisible(); processListPage.getDefinitionIds().splice(0, 1); }); diff --git a/tests/ms2/testUtils/index.ts b/tests/ms2/testUtils/index.ts index 902a547a0..b19725b94 100644 --- a/tests/ms2/testUtils/index.ts +++ b/tests/ms2/testUtils/index.ts @@ -55,7 +55,7 @@ export const mockClipboardAPI = async (page: Page) => * * @returns a locator that can be used to get the newly opened modal */ -export async function openModal(page: Page, triggerFunction: () => Promise) { +export async function openModal(page: Page, triggerFunction: () => Promise) { const alreadyOpenCount = await page .locator(`div[aria-modal="true"]:not(.ant-zoom)`) .and(page.locator(`div[aria-modal="true"]:visible`)) @@ -92,7 +92,7 @@ export async function closeModal(modal: Locator, triggerFunction: () => Promise< */ export async function waitForHydration(page: Page) { // this button should be in the header on every page - const accountButton = await page.getByRole('link', { name: 'user' }); + const accountButton = page.locator('a[href$="/profile"].ant-dropdown-trigger'); // the menu that open when hovering over the accountButton only works after the page has been fully hydrated await accountButton.hover(); await page @@ -104,3 +104,76 @@ export async function waitForHydration(page: Page) { await page.mouse.move(0, 0); await page.getByRole('menuitem', { name: 'Account Settings' }).waitFor({ state: 'hidden' }); } + +export async function useMailInbox(use: (client: any) => Promise) { + const { ImapFlow } = require('imapflow'); + + const client = new ImapFlow({ + host: process.env.TEST_EMAIL_HOST, + port: 993, + secure: true, + auth: { + user: process.env.TEST_EMAIL, + pass: process.env.TEST_EMAIL_PASSWORD, + }, + logger: false, + }); + let lock: any; + let response: T; + + try { + await client.connect(); + + lock = await client.getMailboxLock('INBOX'); + + return await use(client); + } finally { + if (lock) lock.release(); + await client.logout(); + } +} + +/** + * get signin link from email inbox + * + * NOTE: this function deletes all emails from no-reply until it finds a match that respects the + * sentDate, thus it should not be used concurrently + * */ +export async function getSigninLink(sentDate: Date) { + return await useMailInbox(async (client) => { + const sender = 'no-reply@proceed-labs.org'; + let foundLink: string | undefined | null; + + // for timing + const start = Date.now(); + const waitForEmailTimeout = 20_000; + const receivedDateError = 10_000; + const coolDown = 2000; + + while (Date.now() - start < waitForEmailTimeout && !foundLink) { + const seenMails: string[] = []; + + for await (let message of client.fetch( + { from: sender }, + { bodyParts: ['TEXT'], envelope: true }, + )) { + seenMails.push(message.seq); + const text: string = message.bodyParts.get('text').toString().replace(/=\r\n/g, ''); + const afterSentDate = + message.envelope.date.getTime() >= sentDate.getTime() - receivedDateError; + // + let signinLink = text.match(/\n(https:\/\/.*callback\/email.*)/)?.[1]; + signinLink = signinLink?.replace(/=3D/g, '='); + + if (signinLink && afterSentDate) foundLink = signinLink; + } + + // you can't delete emails whilst reading them + for (const seq of seenMails) await client.messageDelete(seq); + + await new Promise((res) => setTimeout(res, coolDown)); + } + + return foundLink; + }); +} diff --git a/yarn.lock b/yarn.lock index 21d4bb29c..d50f68b97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5219,6 +5219,11 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + autoprefixer@^9.5.1: version "9.8.8" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.8.tgz#fd4bd4595385fa6f06599de749a4d5f7a474957a" @@ -6319,6 +6324,14 @@ buffer@^5.1.0, buffer@^5.2.1, buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + builder-util-runtime@8.9.2: version "8.9.2" resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-8.9.2.tgz#a9669ae5b5dcabfe411ded26678e7ae997246c28" @@ -10267,7 +10280,7 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== -events@^3.0.0, events@^3.2.0: +events@^3.0.0, events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -10624,6 +10637,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-redact@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.5.0.tgz#e9ea02f7e57d0cd8438180083e93077e496285e4" + integrity sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A== + fast-text-encoding@^1.0.0: version "1.0.6" resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" @@ -12375,7 +12393,7 @@ iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.13, iconv-lite@^0.4.17, iconv dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.6.2: +iconv-lite@0.6.3, iconv-lite@^0.6.2: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -12443,6 +12461,21 @@ ignore@^5.1.1, ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== +imapflow@^1.0.164: + version "1.0.164" + resolved "https://registry.yarnpkg.com/imapflow/-/imapflow-1.0.164.tgz#c883fd263fab4b0096ecde66e59f65222659d554" + integrity sha512-+KAmLrpqq2Q0Ts1imMP4svydfhYznlvlLHhgtTb8NiIcccZvdRNfdHVP8/RrGaw0hy0TOaluawsm/6q+TqdLPw== + dependencies: + encoding-japanese "2.2.0" + iconv-lite "0.6.3" + libbase64 "1.3.0" + libmime "5.3.5" + libqp "2.1.0" + mailsplit "5.4.0" + nodemailer "6.9.14" + pino "9.2.0" + socks "2.8.3" + immediate@~3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" @@ -15115,6 +15148,16 @@ libphonenumber-js@^1.11.1: resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.11.1.tgz#2596683e1876bfee74082bb49339fe0a85ae34f9" integrity sha512-Wze1LPwcnzvcKGcRHFGFECTaLzxOtujwpf924difr5zniyYv1C2PiW0419qDR7m8lKDxsImu5mwxFuXhXpjmvw== +libqp@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/libqp/-/libqp-2.0.1.tgz#b8fed76cc1ea6c9ceff8888169e4e0de70cd5cf2" + integrity sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg== + +libqp@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/libqp/-/libqp-2.1.0.tgz#ce84bffd86b76029032093bd866d316e12a3d3f5" + integrity sha512-O6O6/fsG5jiUVbvdgT7YX3xY3uIadR6wEZ7+vy9u7PKHAlSEB6blvC1o5pHBjgsi95Uo0aiBBdkyFecj6jtb7A== + lie@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" @@ -15480,6 +15523,15 @@ machine-uuid@^1.2.0: dependencies: uuid "^3.1.0" +mailsplit@5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/mailsplit/-/mailsplit-5.4.0.tgz#9f4692fadd9013e9ce632147d996931d2abac6ba" + integrity sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA== + dependencies: + libbase64 "1.2.1" + libmime "5.2.0" + libqp "2.0.1" + make-dir@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" @@ -16530,6 +16582,11 @@ nodemailer@6.9.13: resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.13.tgz#5b292bf1e92645f4852ca872c56a6ba6c4a3d3d6" integrity sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA== +nodemailer@6.9.14: + version "6.9.14" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.14.tgz#845fda981f9fd5ac264f4446af908a7c78027f75" + integrity sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA== + nodemon@^2.0.12: version "2.0.22" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.22.tgz#182c45c3a78da486f673d6c1702e00728daf5258" @@ -17033,6 +17090,11 @@ omggif@1.0.9: resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.9.tgz#dcb7024dacd50c52b4d303f04802c91c057c765f" integrity sha512-VYAQRSZo7qoBcwB5G29YqVPLnxvDkWulE3x35kwH3bq4GdH/ZkHrcPPhxVfaOGYGZ5KV2/55UpcjcyNIO1qZoQ== +on-exit-leak-free@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" + integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== + on-finished@2.4.1, on-finished@^2.3.0: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -17787,6 +17849,36 @@ pinkie@^2.0.0: resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg== +pino-abstract-transport@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz#97f9f2631931e242da531b5c66d3079c12c9d1b5" + integrity sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q== + dependencies: + readable-stream "^4.0.0" + split2 "^4.0.0" + +pino-std-serializers@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" + integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== + +pino@9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.2.0.tgz#e77a9516f3a3e5550d9b76d9f65ac6118ef02bdd" + integrity sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^1.2.0" + pino-std-serializers "^7.0.0" + process-warning "^3.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + pirates@^4.0.4, pirates@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" @@ -18436,6 +18528,11 @@ process-nextick-args@^2.0.1, process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +process-warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-3.0.0.tgz#96e5b88884187a1dce6f5c3166d611132058710b" + integrity sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ== + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -18784,6 +18881,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + quick-lru@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" @@ -19344,6 +19446,17 @@ readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stre string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^4.0.0: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + readable-web-to-node-stream@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" @@ -19367,6 +19480,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +real-require@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" + integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== + realpath-native@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" @@ -21129,7 +21247,7 @@ string.prototype.trimstart@^1.0.7, string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.0.0, string_decoder@^1.1.1: +string_decoder@^1.0.0, string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== @@ -21641,6 +21759,13 @@ thread-loader@^3.0.0: neo-async "^2.6.2" schema-utils "^3.0.0" +thread-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" + integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== + dependencies: + real-require "^0.2.0" + throat@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a"