diff --git a/.env.example b/.env.example index ea00adf9d9..2c43d6dde0 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,24 @@ -API_URL=/editor +API_URL=https://openprocessing.org/api +API_TOKEN=op_ AWS_ACCESS_KEY= AWS_REGION= AWS_SECRET_KEY= CORS_ALLOW_LOCALHOST=true EMAIL_SENDER= -EMAIL_VERIFY_SECRET_TOKEN=whatever_you_want_this_to_be_it_only_matters_for_production -EXAMPLE_USER_EMAIL=examples@p5js.org -EXAMPLE_USER_PASSWORD=hellop5js -GG_EXAMPLES_USERNAME=generativedesign -GG_EXAMPLES_EMAIL=benedikt.gross@generative-gestaltung.de -GG_EXAMPLES_PASS=generativedesign -GITHUB_ID= -GITHUB_SECRET= -GOOGLE_ID= (use google+ api) -GOOGLE_SECRET= (use google+ api) MAILGUN_DOMAIN= MAILGUN_KEY= -ML5_LIBRARY_USERNAME=ml5 -ML5_LIBRARY_EMAIL=examples@ml5js.org -ML5_LIBRARY_PASS=helloml5 -MONGO_URL=mongodb://localhost:27017/p5js-web-editor +# Optional: public OpenProcessing sketch (visualID) used as the animated +# background on the 404 page. Leave unset for a plain 404 page. +OP_404_SKETCH_ID= PORT=8000 PREVIEW_PORT=8002 EDITOR_URL=http://localhost:8000 PREVIEW_URL=http://localhost:8002 +# Path to the OpenProcessing collection shown as the editor's "Examples" menu +# item, e.g. /username/collections/collectionId. Leave unset to hide Examples. +EXAMPLES_ENDPOINT= S3_BUCKET= S3_BUCKET_URL_BASE= -SESSION_SECRET=whatever_you_want_this_to_be_it_only_matters_for_production TRANSLATIONS_ENABLED=true UI_ACCESS_TOKEN_ENABLED=false UPLOAD_LIMIT=250000000 diff --git a/.gitignore b/.gitignore index e96e5328de..64b8d222af 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ duplicates.json coverage -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo +/docs +/.claude diff --git a/.verify_boot.js b/.verify_boot.js new file mode 100644 index 0000000000..21e268da97 --- /dev/null +++ b/.verify_boot.js @@ -0,0 +1,6 @@ +process.env.NODE_ENV='test'; process.env.PORT='0'; process.env.PREVIEW_PORT='0'; +process.env.API_URL='https://example.org/api'; process.env.EDITOR_URL='http://localhost:8000'; +require('@babel/register')({extensions:['.js','.jsx','.ts','.tsx'],presets:['@babel/preset-env','@babel/preset-typescript']}); +require('regenerator-runtime/runtime'); +try { require('./server/server.js'); require('./server/previewServer.js'); console.log('OK: both servers boot'); } +catch(e){ console.error('BOOT ERROR:', e.message); process.exit(1); } diff --git a/Procfile b/Procfile index 90267a97a9..67f115beb1 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: MONGO_URL=$MONGO_URI MAILGUN_KEY=$MAILGUN_API_KEY npm run start:prod +web: MAILGUN_KEY=$MAILGUN_API_KEY npm run start:prod diff --git a/app.json b/app.json index 3eff82c9fb..0d7b6d49ec 100644 --- a/app.json +++ b/app.json @@ -5,10 +5,6 @@ "logo": "https://p5js.org/assets/img/p5js.svg", "keywords": ["processing", "p5js", "p5.js"], "addons": [ - { - "plan": "mongolab:sandbox", - "as": "MONGO" - }, { "plan": "mailgun:starter", "as": "MAILGUN" @@ -16,7 +12,8 @@ ], "env": { "API_URL": { - "value": "/editor" + "description": "Base URL of the OpenProcessing API the editor reads from.", + "value": "https://openprocessing.org/api" }, "AWS_ACCESS_KEY": { "description": "AWS Access Key", @@ -34,43 +31,6 @@ "description": "The sending email address for transactional emails.", "value": "no-reply@mydomain.com" }, - "EMAIL_VERIFY_SECRET_TOKEN": { - "description": "A secret key for...? Not sure where used.", - "generator": "secret" - }, - "EXAMPLE_USER_EMAIL": { - "description": "The email address for the account holding the default Example sketches", - "value": "examples@p5js.org" - }, - "EXAMPLE_USER_PASSWORD": { - "value": "hellop5js" - }, - "GG_EXAMPLES_EMAIL": { - "description": "The email address for the account holding the Generative Design Example sketches", - "value": "benedikt.gross@generative-gestaltung.de" - }, - "GG_EXAMPLES_PASS": { - "value": "generativedesign" - }, - "GG_EXAMPLES_USERNAME": { - "value": "generative-design" - }, - "GITHUB_ID": { - "description": "The GitHub Client Id for sign in with GitHub support", - "value": "placeholder" - }, - "GITHUB_SECRET": { - "description": "The GitHub Client Secret", - "value": "placeholder" - }, - "GOOGLE_ID": { - "description": "The Google Client Id for sign in with Google support", - "value": "placeholder" - }, - "GOOGLE_SECRET": { - "description": "The Google Client Secret", - "value": "placeholder" - }, "NODE_ENV": { "value": "production" }, @@ -81,13 +41,6 @@ "S3_BUCKET_URL_BASE": { "description": "S3 bucket URL base", "required": false - }, - "SESSION_SECRET": { - "description": "A secret key for verifying the integrity of signed cookies.", - "generator": "secret" } - }, - "scripts": { - "postdeploy": "MONGO_URL=$MONGO_URI MAILGUN_KEY=$MAILGUN_API_KEY npm run fetch-examples:prod" } } diff --git a/client/constants.js b/client/constants.js index cf1ef18431..99fab71a78 100644 --- a/client/constants.js +++ b/client/constants.js @@ -18,11 +18,6 @@ export const AUTH_USER = 'AUTH_USER'; export const UNAUTH_USER = 'UNAUTH_USER'; export const AUTH_ERROR = 'AUTH_ERROR'; -export const SETTINGS_UPDATED = 'SETTINGS_UPDATED'; - -export const API_KEY_CREATED = 'API_KEY_CREATED'; -export const API_KEY_REMOVED = 'API_KEY_REMOVED'; - export const SET_PROJECT_NAME = 'SET_PROJECT_NAME'; export const RENAME_PROJECT = 'RENAME_PROJECT'; @@ -37,6 +32,7 @@ export const SET_PROJECTS_FOR_COLLECTION_LIST = 'SET_PROJECTS_FOR_COLLECTION_LIST'; export const SET_COLLECTIONS = 'SET_COLLECTIONS'; +export const SET_COLLECTION = 'SET_COLLECTION'; export const CREATE_COLLECTION = 'CREATED_COLLECTION'; export const DELETE_COLLECTION = 'DELETE_COLLECTION'; @@ -104,15 +100,6 @@ export const END_SKETCH_REFRESH = 'END_SKETCH_REFRESH'; export const DETECT_INFINITE_LOOPS = 'DETECT_INFINITE_LOOPS'; export const RESET_INFINITE_LOOPS = 'RESET_INFINITE_LOOPS'; -export const RESET_PASSWORD_INITIATE = 'RESET_PASSWORD_INITIATE'; -export const RESET_PASSWORD_RESET = 'RESET_PASSWORD_RESET'; -export const INVALID_RESET_PASSWORD_TOKEN = 'INVALID_RESET_PASSWORD_TOKEN'; - -export const EMAIL_VERIFICATION_INITIATE = 'EMAIL_VERIFICATION_INITIATE'; -export const EMAIL_VERIFICATION_VERIFY = 'EMAIL_VERIFICATION_VERIFY'; -export const EMAIL_VERIFICATION_VERIFIED = 'EMAIL_VERIFICATION_VERIFIED'; -export const EMAIL_VERIFICATION_INVALID = 'EMAIL_VERIFICATION_INVALID'; - // eventually, handle errors more specifically and better export const ERROR = 'ERROR'; @@ -121,6 +108,7 @@ export const RESET_JUST_OPENED_PROJECT = 'RESET_JUST_OPENED_PROJECT'; export const SET_PROJECT_SAVED_TIME = 'SET_PROJECT_SAVED_TIME'; export const RESET_PROJECT_SAVED_TIME = 'RESET_PROJECT_SAVED_TIME'; +export const SET_SAVED_CODE_TITLES = 'SET_SAVED_CODE_TITLES'; export const SET_PREVIOUS_PATH = 'SET_PREVIOUS_PATH'; export const SHOW_ERROR_MODAL = 'SHOW_ERROR_MODAL'; export const HIDE_ERROR_MODAL = 'HIDE_ERROR_MODAL'; @@ -140,7 +128,5 @@ export const CLOSE_SKETCHLIST_MODAL = 'CLOSE_SKETCHLIST_MODAL'; export const START_SAVING_PROJECT = 'START_SAVING_PROJECT'; export const END_SAVING_PROJECT = 'END_SAVING_PROJECT'; -export const SET_COOKIE_CONSENT = 'SET_COOKIE_CONSENT'; - export const CONSOLE_EVENT = 'CONSOLE_EVENT'; export const CLEAR_CONSOLE = 'CLEAR_CONSOLE'; diff --git a/client/modules/IDE/actions/assets.js b/client/modules/IDE/actions/assets.js index 95e51a332a..9aa95d1d24 100644 --- a/client/modules/IDE/actions/assets.js +++ b/client/modules/IDE/actions/assets.js @@ -1,24 +1,69 @@ -import { apiClient } from '../../../utils/apiClient'; +import { opApiClient } from '../../../utils/opApiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; import { assetsActions } from '../reducers/assets'; +import { showToast } from './toast'; const { setAssets, deleteAsset } = assetsActions; +function encodeFilePath(filePath) { + return filePath.split('/').map(encodeURIComponent).join('/'); +} + +function getTransactionErrorMessage(error, fallbackMessage) { + const data = error?.response?.data; + return ( + data?.message || + data?.error || + (typeof data === 'string' ? data : undefined) || + error?.message || + fallbackMessage + ); +} + +function normalizeOpAsset(asset) { + const visualID = asset.visualID == null ? null : String(asset.visualID); + return { + key: `${visualID ?? 'unknown'}:${asset.name}:${asset.url}`, + name: asset.name, + url: asset.url, + size: asset.size, + lastModified: asset.lastModified, + visualID, + sketchId: visualID, + sketchName: asset.visualTitle + }; +} + export function getAssets() { - return async (dispatch) => { + return async (dispatch, getState) => { + const { user } = getState(); + const userID = user?.id; + + // Wait until auth hydration (/whoami) provides the current user's ID. + if (!userID) { + return; + } + dispatch(startLoader()); try { - const response = await apiClient.get('/S3/objects'); + const response = await opApiClient.get(`/user/${userID}/files`); + const assets = response.data.map(normalizeOpAsset); const assetData = { - assets: response.data.assets, - totalSize: response.data.totalSize + assets, + totalSize: assets.reduce((total, asset) => total + asset.size, 0) }; dispatch(setAssets(assetData)); dispatch(stopLoader()); } catch (error) { + dispatch( + showToast( + getTransactionErrorMessage(error, 'Failed to load assets.'), + 5000 + ) + ); dispatch({ type: ActionTypes.ERROR }); @@ -28,14 +73,25 @@ export function getAssets() { } export function deleteAssetRequest(assetKey) { - return async (dispatch) => { + return async (dispatch, getState) => { try { - const path = assetKey.split('/').pop(); - await apiClient.delete( - `/S3/delete?objectKey=${encodeURIComponent(path)}` + const asset = getState().assets.list.find( + (item) => item.key === assetKey + ); + if (!asset?.visualID) { + throw new Error('Only sketch files can be deleted.'); + } + await opApiClient.delete( + `/sketch/${asset.visualID}/files/${encodeFilePath(asset.name)}` ); dispatch(deleteAsset(assetKey)); } catch (error) { + dispatch( + showToast( + getTransactionErrorMessage(error, 'Failed to delete asset.'), + 5000 + ) + ); dispatch({ type: ActionTypes.ERROR }); diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js index 68a42f500d..1c72460496 100644 --- a/client/modules/IDE/actions/collections.js +++ b/client/modules/IDE/actions/collections.js @@ -1,64 +1,134 @@ import browserHistory from '../../../browserHistory'; -import { apiClient } from '../../../utils/apiClient'; +import { opApiClient } from '../../../utils/opApiClient'; +import { + collectionToOpCurationPayload, + opCurationToCollection, + opCurationToCollectionId, + opCurationWithSketchesToCollection +} from '../../../utils/opCurationAdapter'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; import { setToastText, showToast } from './toast'; const TOAST_DISPLAY_TIME_MS = 1500; +const MAX_PAGE_SIZE = 1000; + +function getErrorPayload(error, fallbackMessage = 'Request failed.') { + const data = error?.response?.data; + if (data && typeof data === 'object') { + return data; + } + return { message: data || error?.message || fallbackMessage }; +} + +// Fetch a single curation together with its sketches and build a fully +// populated collection object (the shape the store/components expect). +function fetchCollectionWithItems(collectionId) { + return Promise.all([ + opApiClient.get(`/curation/${collectionId}`), + opApiClient.get(`/curation/${collectionId}/sketches`, { + params: { limit: MAX_PAGE_SIZE, sort: 'desc' } + }) + ]).then(([curationRes, sketchesRes]) => + opCurationWithSketchesToCollection(curationRes.data, sketchesRes.data || []) + ); +} export function getCollections(username) { - return (dispatch) => { + return (dispatch, getState) => { dispatch(startLoader()); - let url; - if (username) { - url = `/${username}/collections`; - } else { - url = '/collections'; - } - return apiClient - .get(url) + const owner = username || getState().user.username; + return opApiClient + .get(`/user/@${owner}/curations`, { + params: { limit: MAX_PAGE_SIZE, sort: 'desc' } + }) .then((response) => { + const collections = (response.data || []).map((curation) => + opCurationToCollection(curation, owner) + ); dispatch({ type: ActionTypes.SET_COLLECTIONS, - collections: response.data + collections + }); + dispatch(stopLoader()); + }) + .catch((error) => { + dispatch({ + type: ActionTypes.ERROR, + error: getErrorPayload(error) + }); + dispatch(stopLoader()); + }); + }; +} + +// Load a single collection (with its sketches) and upsert it into the store. +export function getCollection(collectionId) { + return (dispatch) => { + dispatch(startLoader()); + return fetchCollectionWithItems(collectionId) + .then((collection) => { + dispatch({ + type: ActionTypes.SET_COLLECTION, + collection }); dispatch(stopLoader()); + return collection; }) .catch((error) => { dispatch({ type: ActionTypes.ERROR, - error: error?.response?.data + error: getErrorPayload(error) }); dispatch(stopLoader()); }); }; } -export function createCollection(collection) { +// Returns the list of collection ids (slugs) a sketch already belongs to. +export function getCollectionIdsForSketch(projectId) { + return () => + opApiClient + .get(`/sketch/${projectId}/curations`, { + params: { limit: MAX_PAGE_SIZE } + }) + .then((response) => + (response.data || []).map((curation) => + opCurationToCollectionId(curation) + ) + ) + .catch(() => []); +} + +export function createCollection({ name, description }) { return (dispatch) => { dispatch(startLoader()); - const url = '/collections'; - return apiClient - .post(url, collection) + return opApiClient + .post('/curation', collectionToOpCurationPayload({ name, description })) .then((response) => { + const newCollection = opCurationToCollection(response.data); + dispatch({ type: ActionTypes.CREATE_COLLECTION }); dispatch({ - type: ActionTypes.CREATE_COLLECTION + type: ActionTypes.SET_COLLECTION, + collection: newCollection }); dispatch(stopLoader()); - const newCollection = response.data; dispatch(setToastText(`Created "${newCollection.name}"`)); dispatch(showToast(TOAST_DISPLAY_TIME_MS)); const pathname = `/${newCollection.owner.username}/collections/${newCollection.id}`; - const location = { pathname, state: { skipSavingPath: true } }; + browserHistory.push({ + pathname, + state: { skipSavingPath: true } + }); - browserHistory.push(location); + return newCollection; }) .catch((error) => { dispatch({ type: ActionTypes.ERROR, - error: error?.response?.data + error: getErrorPayload(error) }); dispatch(stopLoader()); }); @@ -68,27 +138,25 @@ export function createCollection(collection) { export function addToCollection(collectionId, projectId) { return (dispatch) => { dispatch(startLoader()); - const url = `/collections/${collectionId}/${projectId}`; - return apiClient - .post(url) - .then((response) => { + return opApiClient + .post(`/curation/${collectionId}/sketches/${projectId}`) + .then(() => fetchCollectionWithItems(collectionId)) + .then((collection) => { dispatch({ type: ActionTypes.ADD_TO_COLLECTION, - payload: response.data + payload: collection }); dispatch(stopLoader()); - const collectionName = response.data.name; - - dispatch(setToastText(`Added to "${collectionName}"`)); + dispatch(setToastText(`Added to "${collection.name}"`)); dispatch(showToast(TOAST_DISPLAY_TIME_MS)); - return response.data; + return collection; }) .catch((error) => { dispatch({ type: ActionTypes.ERROR, - error: error?.response?.data + error: getErrorPayload(error) }); dispatch(stopLoader()); }); @@ -98,27 +166,25 @@ export function addToCollection(collectionId, projectId) { export function removeFromCollection(collectionId, projectId) { return (dispatch) => { dispatch(startLoader()); - const url = `/collections/${collectionId}/${projectId}`; - return apiClient - .delete(url) - .then((response) => { + return opApiClient + .delete(`/curation/${collectionId}/sketches/${projectId}`) + .then(() => fetchCollectionWithItems(collectionId)) + .then((collection) => { dispatch({ type: ActionTypes.REMOVE_FROM_COLLECTION, - payload: response.data + payload: collection }); dispatch(stopLoader()); - const collectionName = response.data.name; - - dispatch(setToastText(`Removed from "${collectionName}"`)); + dispatch(setToastText(`Removed from "${collection.name}"`)); dispatch(showToast(TOAST_DISPLAY_TIME_MS)); - return response.data; + return collection; }) .catch((error) => { dispatch({ type: ActionTypes.ERROR, - error: error?.response?.data + error: getErrorPayload(error) }); dispatch(stopLoader()); }); @@ -126,31 +192,33 @@ export function removeFromCollection(collectionId, projectId) { } export function editCollection(collectionId, { name, description }) { - return (dispatch) => { - const url = `/collections/${collectionId}`; - return apiClient - .patch(url, { name, description }) - .then((response) => { + return (dispatch) => + opApiClient + .patch( + `/curation/${collectionId}`, + collectionToOpCurationPayload({ name, description }) + ) + // Refetch with items so the stored collection keeps its sketches. + .then(() => fetchCollectionWithItems(collectionId)) + .then((collection) => { dispatch({ type: ActionTypes.EDIT_COLLECTION, - payload: response.data + payload: collection }); - return response.data; + return collection; }) .catch((error) => { dispatch({ type: ActionTypes.ERROR, - error: error?.response?.data + error: getErrorPayload(error) }); }); - }; } export function deleteCollection(collectionId) { - return (dispatch) => { - const url = `/collections/${collectionId}`; - return apiClient - .delete(url) + return (dispatch) => + opApiClient + .delete(`/curation/${collectionId}`) .then((response) => { dispatch({ type: ActionTypes.DELETE_COLLECTION, @@ -162,8 +230,7 @@ export function deleteCollection(collectionId) { .catch((error) => { dispatch({ type: ActionTypes.ERROR, - error: error?.response?.data + error: getErrorPayload(error) }); }); - }; } diff --git a/client/modules/IDE/actions/files.js b/client/modules/IDE/actions/files.js index 3d406bcff6..2b462b82ac 100644 --- a/client/modules/IDE/actions/files.js +++ b/client/modules/IDE/actions/files.js @@ -1,6 +1,7 @@ import objectID from 'bson-objectid'; import blobUtil from 'blob-util'; -import { apiClient } from '../../../utils/apiClient'; +import { opApiClient } from '../../../utils/opApiClient'; +import { getFilePath } from '../../../utils/opSketchAdapter'; import * as ActionTypes from '../../../constants'; import { setUnsavedChanges, @@ -8,33 +9,47 @@ import { closeNewFileModal, setSelectedFile } from './ide'; -import { setProjectSavedTime } from './project'; import { createError } from './ide'; +import { showToast } from './toast'; -export function appendToFilename(filename, string) { - const dotIndex = filename.lastIndexOf('.'); - if (dotIndex === -1) return filename + string; +function getTransactionErrorMessage(error, fallbackMessage) { + const data = error?.response?.data; return ( - filename.substring(0, dotIndex) + string + filename.substring(dotIndex) + data?.message || + data?.error || + (typeof data === 'string' ? data : undefined) || + error?.message || + fallbackMessage ); } -export function createUniqueName(name, parentId, files) { +function showFileTransactionError(dispatch, error, fallbackMessage) { + const message = getTransactionErrorMessage(error, fallbackMessage); + dispatch(showToast(message, 5000)); + dispatch(createError({ message })); +} + +function getFormError(error) { + return error?.response?.data ?? { message: error.message }; +} + +function getFormNameError(error) { + const formError = getFormError(error); + return formError?.message || formError?.error || error.message; +} + +function validateAvailableName(name, parentId, files) { const siblingFiles = files .find((file) => file.id === parentId) .children.map((childFileId) => files.find((file) => file.id === childFileId) - ); - let testName = name; - let index = 1; - let existingName = siblingFiles.find((file) => name === file.name); - - while (existingName) { - testName = appendToFilename(name, `-${index}`); - index += 1; - existingName = siblingFiles.find((file) => testName === file.name); // eslint-disable-line + ) + .filter(Boolean); + const existingName = siblingFiles.find((file) => name === file.name); + if (existingName) { + throw new Error('File/Folder already exists.'); } - return testName; + return name; } export function updateFileContent(id, content) { @@ -53,48 +68,109 @@ export function createFile(file, parentId) { }; } -export function submitFile(formProps, files, parentId, projectId) { - if (projectId) { - const postParams = { - name: createUniqueName(formProps.name, parentId, files), - url: formProps.url, - content: formProps.content || '', - parentId, - children: [] - }; - return apiClient - .post(`/projects/${projectId}/files`, postParams) - .then((response) => ({ - file: response.data.updatedFile, - updatedAt: response.data.project.updatedAt - })); +function getParentPath(files, parentId) { + const parent = files.find((file) => file.id === parentId); + if (!parent || parent.name === 'root') { + return ''; } + return getFilePath(parent); +} + +function getTargetFilePath(files, parentId, name) { + const parentPath = getParentPath(files, parentId); + return parentPath ? `${parentPath}/${name}` : name; +} + +function findExistingFileByPath(files, parentId, name) { + const targetPath = getTargetFilePath(files, parentId, name); + return files.find((file) => getFilePath(file) === targetPath); +} + +function getAllDescendantIds(files, nodeId) { + const parentFile = files.find((file) => file.id === nodeId); + if (!parentFile) return []; + return parentFile.children.reduce( + (acc, childId) => [...acc, childId, ...getAllDescendantIds(files, childId)], + [] + ); +} + +function getDescendantFiles(files, nodeId) { + return getAllDescendantIds(files, nodeId) + .map((fileId) => files.find((file) => file.id === fileId)) + .filter(Boolean); +} + +function getRenamedPath(file, oldFolderPath, newFolderPath) { + const filePath = getFilePath(file); + const relativePath = filePath.slice(oldFolderPath.length + 1); + return `${newFolderPath}/${relativePath}`; +} + +function encodeFilePath(filePath) { + return filePath.split('/').map(encodeURIComponent).join('/'); +} + +export function submitFile( + formProps, + files, + parentId, + projectId, + options = {} +) { const id = objectID().toHexString(); + let fileName; + try { + fileName = options.preserveName + ? formProps.name + : validateAvailableName(formProps.name, parentId, files); + } catch (error) { + return Promise.reject(error); + } const file = { - name: createUniqueName(formProps.name, parentId, files), + name: fileName, id, _id: id, url: formProps.url, content: formProps.content || '', children: [] }; + if (projectId) { + file.projectId = projectId; + } return Promise.resolve({ file }); } -export function handleCreateFile(formProps, setSelected = true) { +export function handleCreateFile(formProps, setSelected = true, options = {}) { return (dispatch, getState) => { const state = getState(); const { files } = state; const { parentId } = state.ide; const projectId = state.project.id; return new Promise((resolve) => { - submitFile(formProps, files, parentId, projectId) + const existingFile = options.overwrite + ? findExistingFileByPath(files, parentId, formProps.name) + : null; + + if (existingFile?.url) { + dispatch({ + type: ActionTypes.UPDATE_FILE_NAME, + id: existingFile.id, + name: existingFile.name, + url: formProps.url + }); + dispatch(closeNewFileModal()); + dispatch(setUnsavedChanges(true)); + resolve({ file: existingFile, overwritten: true }); + return; + } + + submitFile(formProps, files, parentId, projectId, options) .then((response) => { - const { file, updatedAt } = response; + const { file } = response; dispatch(createFile(file, parentId)); - if (updatedAt) dispatch(setProjectSavedTime(updatedAt)); dispatch(closeNewFileModal()); dispatch(setUnsavedChanges(true)); if (setSelected) { @@ -103,34 +179,28 @@ export function handleCreateFile(formProps, setSelected = true) { resolve(); }) .catch((error) => { - const { response } = error; - dispatch(createError(response.data)); - resolve({ error }); + showFileTransactionError( + dispatch, + error, + 'File/Folder already exists.' + ); + resolve({ name: getFormNameError(error), error }); }); }); }; } export function submitFolder(formProps, files, parentId, projectId) { - if (projectId) { - const postParams = { - name: createUniqueName(formProps.name, parentId, files), - content: '', - children: [], - parentId, - fileType: 'folder' - }; - return apiClient - .post(`/projects/${projectId}/files`, postParams) - .then((response) => ({ - file: response.data.updatedFile, - updatedAt: response.data.project.updatedAt - })); - } const id = objectID().toHexString(); + let folderName; + try { + folderName = validateAvailableName(formProps.name, parentId, files); + } catch (error) { + return Promise.reject(error); + } const file = { type: ActionTypes.CREATE_FILE, - name: createUniqueName(formProps.name, parentId, files), + name: folderName, id, _id: id, content: '', @@ -138,6 +208,9 @@ export function submitFolder(formProps, files, parentId, projectId) { fileType: 'folder', children: [] }; + if (projectId) { + file.projectId = projectId; + } return Promise.resolve({ file }); @@ -152,66 +225,162 @@ export function handleCreateFolder(formProps) { return new Promise((resolve) => { submitFolder(formProps, files, parentId, projectId) .then((response) => { - const { file, updatedAt } = response; + const { file } = response; dispatch(createFile(file, parentId)); - if (updatedAt) dispatch(setProjectSavedTime(updatedAt)); dispatch(closeNewFolderModal()); dispatch(setUnsavedChanges(true)); resolve(); }) .catch((error) => { - const { response } = error; - dispatch(createError(response.data)); - resolve({ error }); + showFileTransactionError( + dispatch, + error, + 'File/Folder already exists.' + ); + resolve({ name: getFormNameError(error), error }); }); }); }; } export function updateFileName(id, name) { - return (dispatch) => { - dispatch(setUnsavedChanges(true)); + return async (dispatch, getState) => { + const state = getState(); + const file = state.files.find((candidate) => candidate.id === id); + let updatedName = name; + let updatedUrl; + let urlsById = {}; + + if (state.project.id && file?.url) { + try { + const response = await opApiClient.patch( + `/sketch/${state.project.id}/files/${encodeFilePath( + getFilePath(file) + )}`, + { name } + ); + updatedName = response.data.name || name; + updatedUrl = response.data.url; + } catch (error) { + showFileTransactionError(dispatch, error, 'Failed to rename file.'); + return { error }; + } + } else if (state.project.id && file?.fileType === 'folder') { + const oldFolderPath = getFilePath(file); + const newFolderPath = file.filePath ? `${file.filePath}/${name}` : name; + const assetFiles = getDescendantFiles(state.files, id).filter( + (descendant) => descendant.url + ); + + try { + const responses = await Promise.all( + assetFiles.map((assetFile) => + opApiClient + .patch( + `/sketch/${state.project.id}/files/${encodeFilePath( + getFilePath(assetFile) + )}`, + { + name: getRenamedPath(assetFile, oldFolderPath, newFolderPath) + } + ) + .then((response) => ({ + id: assetFile.id, + url: response.data.url + })) + ) + ); + urlsById = responses.reduce( + (acc, response) => ({ + ...acc, + [response.id]: response.url + }), + {} + ); + } catch (error) { + showFileTransactionError(dispatch, error, 'Failed to rename folder.'); + return { error }; + } + dispatch(setUnsavedChanges(true)); + } else { + dispatch(setUnsavedChanges(true)); + } + dispatch({ type: ActionTypes.UPDATE_FILE_NAME, id, - name + name: updatedName, + url: updatedUrl, + urlsById }); + return { name: updatedName, url: updatedUrl, urlsById }; }; } export function deleteFile(id, parentId) { - return (dispatch, getState) => { + return async (dispatch, getState) => { const state = getState(); - if (state.project.id) { - const deleteConfig = { - params: { - parentId - } - }; - apiClient - .delete(`/projects/${state.project.id}/files/${id}`, deleteConfig) - .then((response) => { - dispatch(setProjectSavedTime(response.data.project.updatedAt)); - dispatch({ - type: ActionTypes.DELETE_FILE, - id, - parentId - }); - }) - .catch((error) => { - const { response } = error; - dispatch({ - type: ActionTypes.ERROR, - error: response.data - }); - }); - } else { + const file = state.files.find((candidate) => candidate.id === id); + const descendants = [file, ...getDescendantFiles(state.files, id)].filter( + Boolean + ); + const assetFilesToDelete = descendants.filter( + (candidate) => candidate?.url + ); + const codeTitlesToDelete = descendants + .filter( + (candidate) => + candidate?.fileType === 'file' && + !candidate.url && + state.project.savedCodeTitles?.includes(getFilePath(candidate)) + ) + .map(getFilePath); + + if ( + state.project.id && + (assetFilesToDelete.length > 0 || codeTitlesToDelete.length > 0) + ) { + const requests = [ + ...assetFilesToDelete.map((fileToDelete) => + opApiClient.delete( + `/sketch/${state.project.id}/files/${encodeFilePath( + getFilePath(fileToDelete) + )}` + ) + ), + ...codeTitlesToDelete.map((title) => + opApiClient.delete( + `/sketch/${state.project.id}/code/${encodeURIComponent(title)}` + ) + ) + ]; + + try { + await Promise.all(requests); + } catch (error) { + showFileTransactionError(dispatch, error, 'Failed to delete file.'); + return; + } + } + + if (codeTitlesToDelete.length > 0) { dispatch({ - type: ActionTypes.DELETE_FILE, - id, - parentId + type: ActionTypes.SET_SAVED_CODE_TITLES, + titles: state.project.savedCodeTitles.filter( + (title) => !codeTitlesToDelete.includes(title) + ) }); } + + dispatch({ + type: ActionTypes.DELETE_FILE, + id, + parentId + }); + + if (!file?.url) { + dispatch(setUnsavedChanges(true)); + } }; } diff --git a/client/modules/IDE/actions/preferences.ts b/client/modules/IDE/actions/preferences.ts index f626a20002..090b99caa0 100644 --- a/client/modules/IDE/actions/preferences.ts +++ b/client/modules/IDE/actions/preferences.ts @@ -1,7 +1,6 @@ import i18next from 'i18next'; -import { UpdatePreferencesRequestBody } from '../../../../common/types'; -import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; +import { savePreferences } from '../../../persistPreferences'; import type { UpdatePreferencesDispatch, SetPreferencesTabValue, @@ -21,19 +20,10 @@ import type { } from './preferences.types'; import type { GetRootState } from '../../../reducers'; -function updatePreferences( - formParams: UpdatePreferencesRequestBody, - dispatch: UpdatePreferencesDispatch -) { - apiClient - .put('/preferences', formParams) - .then(() => {}) - .catch((error) => { - dispatch({ - type: ActionTypes.ERROR, - error: error?.response?.data - }); - }); +// Preferences persist to localStorage (per browser) rather than the server. +// Persisting after dispatch means we store the already-updated slice. +function persistPreferences(getState: GetRootState) { + savePreferences(getState().preferences); } export function setPreferencesTab(value: SetPreferencesTabValue) { @@ -45,20 +35,11 @@ export function setPreferencesTab(value: SetPreferencesTabValue) { export function setFontSize(value: SetFontSizeValue) { return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { - // eslint-disable-line dispatch({ type: ActionTypes.SET_FONT_SIZE, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - fontSize: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -68,15 +49,7 @@ export function setLineNumbers(value: SetLineNumbersValue) { type: ActionTypes.SET_LINE_NUMBERS, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - lineNumbers: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -88,15 +61,7 @@ export function setAutocloseBracketsQuotes( type: ActionTypes.SET_AUTOCLOSE_BRACKETS_QUOTES, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - autocloseBracketsQuotes: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -106,15 +71,7 @@ export function setAutocompleteHinter(value: SetAutocompleteHinterValue) { type: ActionTypes.SET_AUTOCOMPLETE_HINTER, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - autocompleteHinter: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -124,15 +81,7 @@ export function setAutosave(value: SetAutosaveValue) { type: ActionTypes.SET_AUTOSAVE, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - autosave: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -142,15 +91,7 @@ export function setLinewrap(value: SetLinewrapValue) { type: ActionTypes.SET_LINEWRAP, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - linewrap: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -160,15 +101,7 @@ export function setLintWarning(value: SetLintWarningValue) { type: ActionTypes.SET_LINT_WARNING, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - lintWarning: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -178,15 +111,7 @@ export function setTextOutput(value: SetTextOutputValue) { type: ActionTypes.SET_TEXT_OUTPUT, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - textOutput: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -196,15 +121,7 @@ export function setGridOutput(value: SetGridOutputValue) { type: ActionTypes.SET_GRID_OUTPUT, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - gridOutput: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -214,15 +131,7 @@ export function setTheme(value: SetThemeValue) { type: ActionTypes.SET_THEME, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - theme: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } @@ -232,20 +141,12 @@ export function setAutorefresh(value: SetAutorefreshValue) { type: ActionTypes.SET_AUTOREFRESH, value }); - const state = getState(); - if (state.user.authenticated) { - const formParams = { - preferences: { - autorefresh: value - } - }; - updatePreferences(formParams, dispatch); - } + persistPreferences(getState); }; } export function setAllAccessibleOutput(value: SetAllAccessibleOutputValue) { - return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { + return (dispatch: UpdatePreferencesDispatch) => { dispatch(setTextOutput(value)); dispatch(setGridOutput(value)); }; @@ -261,14 +162,8 @@ export function setLanguage( type: ActionTypes.SET_LANGUAGE, language: value }); - const state = getState(); - if (persistPreference && state.user.authenticated) { - const formParams = { - preferences: { - language: value - } - }; - updatePreferences(formParams, dispatch); + if (persistPreference) { + persistPreferences(getState); } }; } diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 7787f879bb..82fd0eae0f 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -1,9 +1,13 @@ -import objectID from 'bson-objectid'; -import each from 'async/each'; -import { isEqual } from 'lodash'; +import JSZip from 'jszip'; import browserHistory from '../../../browserHistory'; -import { apiClient } from '../../../utils/apiClient'; -import { getConfig } from '../../../utils/getConfig'; +import { opApiClient } from '../../../utils/opApiClient'; +import { + opSketchToProject, + opVisualIdToProjectId, + editorFilesToCodeTabs, + editorFilesToZipEntries, + visibilityToOpPrivacy +} from '../../../utils/opSketchAdapter'; import * as ActionTypes from '../../../constants'; import { showToast, setToastText } from './toast'; import { @@ -16,10 +20,6 @@ import { import { clearLocalBackup } from '../utils/localBackup'; import { clearState, saveState } from '../../../persistState'; -const ROOT_URL = getConfig('API_URL'); -const S3_BUCKET_URL_BASE = getConfig('S3_BUCKET_URL_BASE'); -const S3_BUCKET = getConfig('S3_BUCKET'); - export function setProject(project) { return { type: ActionTypes.SET_PROJECT, @@ -53,21 +53,42 @@ export function setNewProject(project) { }; } -export function getProject(id, username) { - return (dispatch, getState) => { +function getRequestErrorPayload(error, fallbackMessage = 'Request failed.') { + const data = error?.response?.data; + if (data && typeof data === 'object') { + return data; + } + + return { + message: + data || error?.response?.message || error?.message || fallbackMessage + }; +} + +export function getProject(id, ownerUsername) { + return async (dispatch, getState) => { dispatch(justOpenedProject()); - return apiClient - .get(`/${username}/projects/${id}`) - .then((response) => { - dispatch(setProject(response.data)); - dispatch(setUnsavedChanges(false)); - }) - .catch((error) => { - dispatch({ - type: ActionTypes.ERROR, - error: error?.response?.data - }); + try { + const [sketchRes, codeRes, filesRes] = await Promise.all([ + opApiClient.get(`/sketch/${id}`), + opApiClient.get(`/sketch/${id}/code`), + opApiClient.get(`/sketch/${id}/files`) + ]); + const fallbackUsername = getState().user.username ?? ''; + const project = opSketchToProject( + sketchRes.data, + codeRes.data, + ownerUsername ?? fallbackUsername, + filesRes.data + ); + dispatch(setProject(project)); + dispatch(setUnsavedChanges(false)); + } catch (error) { + dispatch({ + type: ActionTypes.ERROR, + error: getRequestErrorPayload(error) }); + } }; } @@ -108,122 +129,138 @@ export function projectSaveSuccess() { }; } -// want a function that will check for changes on the front end -function getSynchedProject(currentState, responseProject) { - let hasChanges = false; - const synchedProject = Object.assign({}, responseProject); - const currentFiles = currentState.files.map( - ({ name, children, content }) => ({ name, children, content }) +function createCodeTabs(visualID, codeTabs) { + return Promise.all( + codeTabs.map((tab, i) => + opApiClient.post( + `/sketch/${visualID}/code/${encodeURIComponent(tab.title)}`, + { code: tab.code, orderID: i } + ) + ) ); - const responseFiles = responseProject.files.map( - ({ name, children, content }) => ({ name, children, content }) +} + +// Diff saved vs current tabs: delete removed ones, then upsert all current ones +function syncCodeTabs(visualID, savedTitles, currentTabs) { + const currentTitles = currentTabs.map((t) => t.title); + const toDelete = savedTitles.filter((t) => !currentTitles.includes(t)); + + return Promise.all( + toDelete.map((title) => + opApiClient.delete( + `/sketch/${visualID}/code/${encodeURIComponent(title)}` + ) + ) + ).then(() => + Promise.all( + currentTabs.map((tab, i) => { + if (savedTitles.includes(tab.title)) { + return opApiClient.put( + `/sketch/${visualID}/code/${encodeURIComponent(tab.title)}`, + { code: tab.code, orderID: i } + ); + } + return opApiClient.post( + `/sketch/${visualID}/code/${encodeURIComponent(tab.title)}`, + { code: tab.code, orderID: i } + ); + }) + ) ); - if (!isEqual(currentFiles, responseFiles)) { - synchedProject.files = currentState.files; - hasChanges = true; - } - if (currentState.project.name !== responseProject.name) { - synchedProject.name = currentState.project.name; - hasChanges = true; - } - return { - synchedProject, - hasChanges - }; } -export function saveProject( - selectedFile = null, - autosave = false, - mobile = false -) { - return (dispatch, getState) => { +export function saveProject(selectedFile = null, autosave = false) { + return async (dispatch, getState) => { const state = getState(); if (state.project.isSaving) { - return Promise.resolve(); + return; } dispatch(startSavingProject()); + if ( state.user.id && state.project.owner && state.project.owner.id !== state.user.id ) { - return Promise.reject(); + dispatch(endSavingProject()); + return; } - const formParams = Object.assign({}, state.project); - formParams.files = [...state.files]; + const files = [...state.files]; if (selectedFile) { - const fileToUpdate = formParams.files.find( - (file) => file.id === selectedFile.id - ); - fileToUpdate.content = selectedFile.content; + const fileToUpdate = files.find((f) => f.id === selectedFile.id); + if (fileToUpdate) fileToUpdate.content = selectedFile.content; } - if (state.project.id) { - return apiClient - .put(`/projects/${state.project.id}`, formParams) - .then((response) => { - dispatch(endSavingProject()); - dispatch(setUnsavedChanges(false)); - // Clear the localStorage backup after successful server save (#3891) - clearLocalBackup(state.project.id); - const { hasChanges, synchedProject } = getSynchedProject( - getState(), - response.data - ); - if (hasChanges) { - dispatch(setUnsavedChanges(true)); - } - dispatch(setProject(synchedProject)); - dispatch(projectSaveSuccess()); - if (!autosave) { - if (state.ide.justOpenedProject && state.preferences.autosave) { - dispatch(showToast(5500)); - dispatch(setToastText('Toast.SketchSaved')); - setTimeout( - () => dispatch(setToastText('Toast.AutosaveEnabled')), - 1500 - ); - dispatch(resetJustOpenedProject()); - } else { - dispatch(showToast(1500)); - dispatch(setToastText('Toast.SketchSaved')); - } - } - }) - .catch((error) => { - const { response } = error; - dispatch(endSavingProject()); - dispatch(setToastText('Toast.SketchFailedSave')); - dispatch(showToast(1500)); - if (response.status === 403) { - dispatch(showErrorModal('staleSession')); - } else if (response.status === 409) { - dispatch(showErrorModal('staleProject')); - } else { - dispatch(projectSaveFail(response.data)); - } + + const codeTabs = editorFilesToCodeTabs(files); + + try { + if (state.project.id) { + // Update existing sketch + const visualID = state.project.id; + + await opApiClient.patch(`/sketch/${visualID}`, { + title: state.project.name, + mode: 'html', + isPrivate: visibilityToOpPrivacy(state.project.visibility) }); - } - return apiClient - .post('/projects', formParams) - .then((response) => { - dispatch(endSavingProject()); - const { hasChanges, synchedProject } = getSynchedProject( - getState(), - response.data + await syncCodeTabs( + visualID, + state.project.savedCodeTitles ?? [], + codeTabs ); - dispatch(setNewProject(synchedProject)); + dispatch(endSavingProject()); dispatch(setUnsavedChanges(false)); - browserHistory.push( - `/${response.data.user.username}/sketches/${response.data.id}` - ); + clearLocalBackup(state.project.id); + dispatch({ + type: ActionTypes.SET_SAVED_CODE_TITLES, + titles: codeTabs.map((t) => t.title) + }); + dispatch(projectSaveSuccess()); - if (hasChanges) { - dispatch(setUnsavedChanges(true)); + if (!autosave) { + if (state.ide.justOpenedProject && state.preferences.autosave) { + dispatch(showToast(5500)); + dispatch(setToastText('Toast.SketchSaved')); + setTimeout( + () => dispatch(setToastText('Toast.AutosaveEnabled')), + 1500 + ); + dispatch(resetJustOpenedProject()); + } else { + dispatch(showToast(1500)); + dispatch(setToastText('Toast.SketchSaved')); + } } + } else { + // Create new sketch + const sketchRes = await opApiClient.post('/sketch', { + title: state.project.name, + mode: 'html', + isPrivate: visibilityToOpPrivacy(state.project.visibility) + }); + + const { visualID } = sketchRes.data; + await createCodeTabs(visualID, codeTabs); + const projectId = opVisualIdToProjectId(visualID); + + const createdProject = { + id: projectId, + name: state.project.name, + visibility: state.project.visibility, + fileBase: sketchRes.data.fileBase, + files, + savedCodeTitles: codeTabs.map((t) => t.title), + updatedAt: sketchRes.data.createdOn ?? '', + user: { username: state.user.username, id: state.user.id } + }; + + dispatch(endSavingProject()); + dispatch(setNewProject(createdProject)); + dispatch(setUnsavedChanges(false)); + browserHistory.push(`/${state.user.username}/sketches/${projectId}`); dispatch(projectSaveSuccess()); if (!autosave) { @@ -240,30 +277,70 @@ export function saveProject( dispatch(setToastText('Toast.SketchSaved')); } } - }) - .catch((error) => { - const { response } = error; - dispatch(endSavingProject()); - dispatch(setToastText('Toast.SketchFailedSave')); - dispatch(showToast(1500)); - if (response.status === 403) { - dispatch(showErrorModal('staleSession')); - } else { - dispatch(projectSaveFail(response.data)); - } - }); + } + } catch (error) { + const { response } = error; + dispatch(endSavingProject()); + dispatch(setToastText('Toast.SketchFailedSave')); + dispatch(showToast(1500)); + if (response?.status === 403) { + dispatch(showErrorModal('staleSession')); + } else if (response?.status === 409) { + dispatch(showErrorModal('staleProject')); + } else { + dispatch(projectSaveFail(getRequestErrorPayload(error))); + } + } }; } -export function autosaveProject(mobile = false) { +export function autosaveProject() { return (dispatch, getState) => { - saveProject(null, true, mobile)(dispatch, getState); + saveProject(null, true)(dispatch, getState); }; } -export function exportProjectAsZip(projectId) { - const win = window.open(`${ROOT_URL}/projects/${projectId}/zip`, '_blank'); - win.focus(); +export function exportProjectAsZip() { + return async (dispatch, getState) => { + const { files, project } = getState(); + const entries = editorFilesToZipEntries(files); + + try { + const zip = new JSZip(); + + await Promise.all( + entries.map(async (entry) => { + if (entry.url) { + // Uploaded asset: fetch the bytes from OP storage (S3/CloudFront). + const res = await fetch(entry.url); + if (!res.ok) { + throw new Error(`Failed to fetch ${entry.path}: ${res.status}`); + } + zip.file(entry.path, await res.blob()); + } else { + zip.file(entry.path, entry.content ?? ''); + } + }) + ); + + const blob = await zip.generateAsync({ type: 'blob' }); + const filename = `sketch${project.id || project.name || 'export'}.zip`; + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + dispatch(setToastText('Toast.SketchDownloaded')); + dispatch(showToast(1500)); + } catch (error) { + dispatch(setToastText('Toast.SketchFailedDownload')); + dispatch(showToast(1500)); + } + }; } export function resetProject() { @@ -277,79 +354,49 @@ export function newProject() { return resetProject(); } -function generateNewIdsForChildren(file, files) { - const newChildren = []; - file.children.forEach((childId) => { - const child = files.find((childFile) => childFile.id === childId); - const newId = objectID().toHexString(); - child.id = newId; - child._id = newId; - newChildren.push(newId); - generateNewIdsForChildren(child, files); - }); - file.children = newChildren; // eslint-disable-line -} - export function cloneProject(project) { - return (dispatch, getState) => { + return async (dispatch, getState) => { dispatch(setUnsavedChanges(false)); const state = getState(); - const files = project ? project.files : state.files; + const sourceID = project ? project.id : state.project.id; const projectName = project ? project.name : state.project.name; - const newFiles = files.map((file) => ({ ...file })); - - // generate new IDS for all files - const rootFile = newFiles.find((file) => file.name === 'root'); - const newRootFileId = objectID().toHexString(); - rootFile.id = newRootFileId; - rootFile._id = newRootFileId; - generateNewIdsForChildren(rootFile, newFiles); - - // duplicate all files hosted on S3 - each( - newFiles, - (file, callback) => { - if ( - file.url && - S3_BUCKET && - S3_BUCKET_URL_BASE && - (file.url.includes(S3_BUCKET_URL_BASE) || - file.url.includes(S3_BUCKET)) - ) { - const formParams = { - url: file.url - }; - apiClient.post('/S3/copy', formParams).then((response) => { - file.url = response.data.url; - callback(null); - }); - } else { - callback(null); - } - }, - (err) => { - // if not errors in duplicating the files on S3, then duplicate it - const formParams = Object.assign( - {}, - { name: `${projectName} copy` }, - { files: newFiles } - ); - apiClient - .post('/projects', formParams) - .then((response) => { - browserHistory.push( - `/${response.data.user.username}/sketches/${response.data.id}` - ); - dispatch(setNewProject(response.data)); - }) - .catch((error) => { - dispatch({ - type: ActionTypes.PROJECT_SAVE_FAIL, - error: error?.response?.data - }); - }); - } - ); + + try { + const codeRes = await opApiClient.get(`/sketch/${sourceID}/code`); + + const sketchRes = await opApiClient.post('/sketch', { + title: `${projectName} copy`, + mode: 'html', + isPrivate: visibilityToOpPrivacy(state.project.visibility) + }); + + const { visualID } = sketchRes.data; + const projectId = opVisualIdToProjectId(visualID); + + await Promise.all( + codeRes.data.map((tab, i) => + opApiClient.post( + `/sketch/${visualID}/code/${encodeURIComponent(tab.title)}`, + { code: tab.code, orderID: i } + ) + ) + ); + + const { username } = state.user; + const clonedProject = opSketchToProject( + { ...sketchRes.data, title: `${projectName} copy` }, + codeRes.data, + username + ); + + dispatch(setNewProject(clonedProject)); + browserHistory.push(`/${username}/sketches/${projectId}`); + } catch (error) { + dispatch({ + type: ActionTypes.PROJECT_SAVE_FAIL, + error: getRequestErrorPayload(error) + }); + } }; } @@ -361,118 +408,106 @@ export function setProjectSavedTime(updatedAt) { } export function changeProjectName(id, newName) { - return (dispatch, getState) => { - const state = getState(); - apiClient - .put(`/projects/${id}`, { name: newName }) - .then((response) => { - if (response.status === 200) { - dispatch({ - type: ActionTypes.RENAME_PROJECT, - payload: { id: response.data.id, name: response.data.name } - }); - if (state.project.id === response.data.id) { - dispatch({ - type: ActionTypes.SET_PROJECT_NAME, - name: response.data.name - }); - } - } - }) - .catch((error) => { + return async (dispatch, getState) => { + try { + await opApiClient.patch(`/sketch/${id}`, { title: newName }); + dispatch({ + type: ActionTypes.RENAME_PROJECT, + payload: { id, name: newName } + }); + const state = getState(); + if (state.project.id === id) { dispatch({ - type: ActionTypes.PROJECT_SAVE_FAIL, - error: error?.response?.data + type: ActionTypes.SET_PROJECT_NAME, + name: newName }); + } + } catch (error) { + dispatch({ + type: ActionTypes.PROJECT_SAVE_FAIL, + error: getRequestErrorPayload(error) }); + } }; } export function deleteProject(id) { - return (dispatch, getState) => - apiClient - .delete(`/projects/${id}`) - .then(() => { - const state = getState(); - if (id === state.project.id) { - dispatch(resetProject()); - dispatch(setPreviousPath('/')); - } + return async (dispatch, getState) => { + try { + await opApiClient.delete(`/sketch/${id}`); + const state = getState(); + if (id === state.project.id) { + dispatch(resetProject()); + dispatch(setPreviousPath('/')); + } + dispatch({ + type: ActionTypes.DELETE_PROJECT, + id + }); + } catch (error) { + const { response } = error; + if (response?.status === 403) { + dispatch(showErrorModal('staleSession')); + } else { dispatch({ - type: ActionTypes.DELETE_PROJECT, - id + type: ActionTypes.ERROR, + error: getRequestErrorPayload(error) }); - }) - .catch((error) => { - const { response } = error; - if (response.status === 403) { - dispatch(showErrorModal('staleSession')); - } else { - dispatch({ - type: ActionTypes.ERROR, - error: response.data - }); - } - }); + } + } + }; } + export function changeVisibility(projectId, projectName, visibility, t) { - return (dispatch, getState) => { + return async (dispatch, getState) => { const state = getState(); + try { + await opApiClient.patch(`/sketch/${projectId}`, { + isPrivate: visibilityToOpPrivacy(visibility) + }); - apiClient - .patch('/project/visibility', { projectId, visibility }) - .then((response) => { - if (response.status === 200) { - const { visibility: newVisibility, updatedAt, id } = response.data; - - dispatch({ - type: ActionTypes.CHANGE_VISIBILITY, - payload: { - id, - visibility: newVisibility - } - }); - - if (state.project.id === response.data.id) { - dispatch({ - type: ActionTypes.SET_PROJECT_VISIBILITY, - visibility: newVisibility, - updatedAt - }); - - dispatch({ - type: ActionTypes.SET_PROJECT_NAME, - name: response.data.name - }); - - let visibilityLabel; - - switch (newVisibility) { - case 'Public': - visibilityLabel = t('Visibility.Public.Label'); - break; - case 'Private': - visibilityLabel = t('Visibility.Private.Label'); - break; - default: - visibilityLabel = newVisibility; - } + dispatch({ + type: ActionTypes.CHANGE_VISIBILITY, + payload: { id: projectId, visibility } + }); - const visibilityToastText = t('Visibility.Changed', { - projectName, - newVisibility: visibilityLabel.toLowerCase() - }); + if (state.project.id === projectId) { + dispatch({ + type: ActionTypes.SET_PROJECT_VISIBILITY, + visibility, + updatedAt: new Date().toISOString() + }); - dispatch(setToastText(visibilityToastText)); - dispatch(showToast(2000)); - } - } - }) - .catch((error) => { dispatch({ - type: ActionTypes.ERROR, - error: error?.response?.data + type: ActionTypes.SET_PROJECT_NAME, + name: projectName + }); + + let visibilityLabel; + switch (visibility) { + case 'Public': + visibilityLabel = t('Visibility.Public.Label'); + break; + case 'Private': + visibilityLabel = t('Visibility.Private.Label'); + break; + default: + visibilityLabel = visibility; + } + + const visibilityToastText = t('Visibility.Changed', { + projectName, + newVisibility: visibilityLabel.toLowerCase() }); + + dispatch(setToastText(visibilityToastText)); + dispatch(showToast(2000)); + } + } catch (error) { + dispatch({ + type: ActionTypes.ERROR, + error: getRequestErrorPayload(error) }); + } }; } diff --git a/client/modules/IDE/actions/projects.js b/client/modules/IDE/actions/projects.js index 3eaaf42972..36fcdc2b38 100644 --- a/client/modules/IDE/actions/projects.js +++ b/client/modules/IDE/actions/projects.js @@ -1,53 +1,175 @@ -import { apiClient } from '../../../utils/apiClient'; +import { opApiClient } from '../../../utils/opApiClient'; +import { + opPrivacyToVisibility, + opVisualIdToProjectId +} from '../../../utils/opSketchAdapter'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; -const buildProjectsUrl = (username, options = {}) => { - const { - page = 1, - limit = 10, - sortField = 'updatedAt', - sortDir = 'desc', - q = '' - } = options; - - const base = username - ? `/${encodeURIComponent(username)}/projects` - : '/projects'; - - const params = new URLSearchParams({ - page: String(page), - limit: String(limit), - sortField, - sortDir - }); +function getRequestErrorPayload(error, fallbackMessage = 'Request failed.') { + const data = error?.response?.data; + if (data && typeof data === 'object') { + return data; + } + + return { + message: + data || error?.response?.message || error?.message || fallbackMessage + }; +} + +function isAbortError(error) { + return ( + error?.code === 'ERR_CANCELED' || + error?.name === 'CanceledError' || + error?.name === 'AbortError' + ); +} + +function getTotalCountHeader(headers) { + return ( + headers?.get?.('x-total-count') ?? + headers?.['x-total-count'] ?? + headers?.['X-Total-Count'] + ); +} + +function normalizeOpProjectsResponse(response, page, limit) { + const projects = response.data.map((s) => ({ + id: opVisualIdToProjectId(s.visualID), + name: s.title, + createdAt: s.createdOn, + updatedAt: s.createdOn, + visibility: opPrivacyToVisibility(s.isPrivate ?? 0) + })); + const totalProjects = Number(getTotalCountHeader(response.headers)); + const normalizedTotalProjects = Number.isFinite(totalProjects) + ? totalProjects + : projects.length; + const totalPages = Math.max(1, Math.ceil(normalizedTotalProjects / limit)); - const trimmed = q.trim(); + return { + projects, + metadata: { + page, + totalPages, + totalProjects: normalizedTotalProjects, + limit, + hasPagination: totalPages > 1 + } + }; +} - if (trimmed) { - params.set('q', trimmed); +const SKETCHES_REQUEST_DELAY_MS = 500; // allows user to finish typing before a request is made + +let projectsRequest; + +function cancelProjectsRequest(dispatch) { + if (!projectsRequest) { + return; } - return `${base}?${params.toString()}`; -}; + const request = projectsRequest; + projectsRequest = undefined; + request.controller.abort(); + + if (request.timeoutId) { + clearTimeout(request.timeoutId); + } else { + dispatch(stopLoader()); + } + + request.resolve([]); +} -const fetchProjects = (username, options, successType) => (dispatch) => { - dispatch(startLoader()); - - const url = buildProjectsUrl(username, options); - - return apiClient - .get(url) - .then((response) => { - dispatch({ type: successType, projects: response.data }); - dispatch(stopLoader()); - return response.data; - }) - .catch((error) => { - dispatch({ type: ActionTypes.ERROR, error: error?.response?.data }); - dispatch(stopLoader()); - throw error; - }); +const fetchProjects = (username, options, successType) => ( + dispatch, + getState +) => { + const { user } = getState(); + const { id: userID } = user; + const isOwnDashboard = + Boolean(userID) && (!username || username === user.username); + + // In OP-backed mode sketches are fetched from /user/{userID}/sketches, so + // wait until auth hydration gives us the current user's ID before requesting. + if (!userID) { + cancelProjectsRequest(dispatch); + return Promise.resolve([]); + } + + if (!isOwnDashboard) { + cancelProjectsRequest(dispatch); + return Promise.resolve([]); + } + + const { page = 1, limit = 10, q } = options ?? {}; + const offset = (page - 1) * limit; + const params = { limit, offset, sort: 'desc' }; + + if (q?.trim()) { + params.q = q.trim(); + } + + cancelProjectsRequest(dispatch); + const requestController = new AbortController(); + + return new Promise((resolve, reject) => { + const request = { + controller: requestController, + resolve, + timeoutId: undefined + }; + + projectsRequest = request; + request.timeoutId = setTimeout(() => { + if (projectsRequest !== request) { + resolve([]); + return; + } + + request.timeoutId = undefined; + dispatch(startLoader()); + + opApiClient + .get(`/user/${userID}/sketches`, { + params, + signal: requestController.signal + }) + .then((response) => normalizeOpProjectsResponse(response, page, limit)) + .then((response) => { + if (projectsRequest !== request) { + resolve([]); + return; + } + + dispatch({ type: successType, projects: response }); + projectsRequest = undefined; + dispatch(stopLoader()); + resolve(response.projects); + }) + .catch((error) => { + if (projectsRequest !== request) { + resolve([]); + return; + } + + projectsRequest = undefined; + dispatch(stopLoader()); + + if (isAbortError(error)) { + resolve([]); + return; + } + + dispatch({ + type: ActionTypes.ERROR, + error: getRequestErrorPayload(error) + }); + reject(error); + }); + }, SKETCHES_REQUEST_DELAY_MS); + }); }; export const getProjects = (username, options) => diff --git a/client/modules/IDE/actions/projects.unit.test.js b/client/modules/IDE/actions/projects.unit.test.js index 95a6205520..3734a09ede 100644 --- a/client/modules/IDE/actions/projects.unit.test.js +++ b/client/modules/IDE/actions/projects.unit.test.js @@ -12,15 +12,27 @@ import { } from '../../../testData/testReduxStore'; const mockStore = configureStore([thunk]); +let lastSearchParams; + +const mockOpSketches = mockProjects.map((project) => ({ + visualID: project.id, + title: project.name, + createdOn: project.createdAt, + isPrivate: 0 +})); const server = setupServer( - rest.get(`/${initialTestState.user.username}/projects`, (req, res, ctx) => - res(ctx.json(mockProjects)) - ) + rest.get(`/user/${initialTestState.user.id}/sketches`, (req, res, ctx) => { + lastSearchParams = req.url.searchParams; + return res(ctx.set('X-Total-Count', '54'), ctx.json(mockOpSketches)); + }) ); beforeAll(() => server.listen()); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + lastSearchParams = undefined; +}); afterAll(() => server.close()); describe('projects action creator tests', () => { @@ -32,10 +44,26 @@ describe('projects action creator tests', () => { it('creates GET_PROJECTS after successfuly fetching projects', () => { store = mockStore(initialTestState); + const expectedProjects = { + projects: mockProjects.map((project) => ({ + id: project.id, + name: project.name, + createdAt: project.createdAt, + updatedAt: project.createdAt, + visibility: project.visibility + })), + metadata: { + page: 1, + totalPages: 6, + totalProjects: 54, + limit: 10, + hasPagination: true + } + }; const expectedActions = [ { type: startLoader.type }, - { type: ActionTypes.SET_PROJECTS, projects: mockProjects }, + { type: ActionTypes.SET_PROJECTS, projects: expectedProjects }, { type: stopLoader.type } ]; @@ -43,4 +71,50 @@ describe('projects action creator tests', () => { .dispatch(ProjectActions.getProjects('happydog')) .then(() => expect(store.getActions()).toEqual(expectedActions)); }); + + it('passes search queries to the OP sketches API', () => { + store = mockStore(initialTestState); + + return store + .dispatch(ProjectActions.getProjects('happydog', { q: ' test ' })) + .then(() => expect(lastSearchParams.get('q')).toBe('test')); + }); + + it('aborts in-flight OP sketch requests when a new request starts', async () => { + store = mockStore(initialTestState); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + + server.use( + rest.get( + `/user/${initialTestState.user.id}/sketches`, + (req, res, ctx) => { + lastSearchParams = req.url.searchParams; + const delay = lastSearchParams.get('q') === 'first' ? 100 : 0; + + return res( + ctx.delay(delay), + ctx.set('X-Total-Count', '54'), + ctx.json(mockOpSketches) + ); + } + ) + ); + + try { + const firstRequest = store.dispatch( + ProjectActions.getProjects('happydog', { q: 'first' }) + ); + const secondRequest = store.dispatch( + ProjectActions.getProjects('happydog', { q: 'second' }) + ); + + await secondRequest; + await firstRequest; + + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(lastSearchParams.get('q')).toBe('second'); + } finally { + abortSpy.mockRestore(); + } + }); }); diff --git a/client/modules/IDE/actions/uploader.js b/client/modules/IDE/actions/uploader.js index 10b48d906f..f911ad324d 100644 --- a/client/modules/IDE/actions/uploader.js +++ b/client/modules/IDE/actions/uploader.js @@ -1,29 +1,93 @@ import { TEXT_FILE_REGEX } from '../../../../server/utils/fileUtils'; -import { apiClient } from '../../../utils/apiClient'; -import { getConfig } from '../../../utils/getConfig'; -import { isTestEnvironment } from '../../../utils/checkTestEnv'; +import { opApiClient } from '../../../utils/opApiClient'; +import { getFilePath } from '../../../utils/opSketchAdapter'; import { handleCreateFile } from './files'; import { showErrorModal } from './ide'; +import { showToast } from './toast'; -const s3BucketUrlBase = getConfig('S3_BUCKET_URL_BASE'); -const awsRegion = getConfig('AWS_REGION'); -const s3Bucket = getConfig('S3_BUCKET'); +const MAX_LOCAL_FILE_SIZE = 80000; // bytes, aka 80 KB +const FILENAME_TEMPLATE = ['$', '{filename}'].join(''); +const uploadPoliciesBySketchId = {}; -if (!isTestEnvironment && !s3BucketUrlBase && !(awsRegion && s3Bucket)) { - throw new Error(`S3 bucket address not configured. - Configure either S3_BUCKET_URL_BASE or both AWS_REGION & S3_BUCKET in env vars`); +function isS3Upload(file) { + return !TEXT_FILE_REGEX.test(file.name) || file.size >= MAX_LOCAL_FILE_SIZE; } -export const s3BucketHttps = - s3BucketUrlBase || `https://s3-${awsRegion}.amazonaws.com/${s3Bucket}/`; +function getTransactionErrorMessage(error, fallbackMessage) { + const data = error?.response?.data; + return ( + data?.message || + data?.error || + data?.responseText?.message || + (typeof data === 'string' ? data : undefined) || + error?.message || + fallbackMessage + ); +} -const MAX_LOCAL_FILE_SIZE = 80000; // bytes, aka 80 KB +function showUploadError(dispatch, file, message) { + dispatch(showToast(message, 5000)); + if (!file.previewElement) { + return; + } + file.previewElement.classList.add('dz-error'); + file.previewElement.classList.remove('dz-success'); + const dzErrorMessageElement = file.previewElement.querySelector( + '[data-dz-errormessage]' + ); + if (dzErrorMessageElement) { + dzErrorMessageElement.textContent = message; + } +} -function isS3Upload(file) { - return !TEXT_FILE_REGEX.test(file.name) || file.size >= MAX_LOCAL_FILE_SIZE; +function buildUploadedFileUrl(fileBase, uploadPath, filename) { + const base = fileBase.endsWith('/') ? fileBase : `${fileBase}/`; + const path = uploadPath ? `${uploadPath}/${filename}` : filename; + return encodeURI(`${base}${path}`); } -export async function dropzoneAcceptCallback(userId, file, done, dispatch) { +function buildS3Key(keyTemplate, uploadPath) { + if (!uploadPath) { + return keyTemplate; + } + return keyTemplate.replace( + FILENAME_TEMPLATE, + `${uploadPath}/${FILENAME_TEMPLATE}` + ); +} + +function getUploadPath(files, parentId) { + const parent = files.find((file) => file.id === parentId); + if (!parent || parent.name === 'root') { + return ''; + } + return getFilePath(parent); +} + +async function getUploadPolicy(projectId) { + if (!uploadPoliciesBySketchId[projectId]) { + uploadPoliciesBySketchId[projectId] = opApiClient + .get(`/sketch/${projectId}/fileUploadPolicy`) + .then((response) => response.data) + .catch((error) => { + delete uploadPoliciesBySketchId[projectId]; + throw error; + }); + } + return uploadPoliciesBySketchId[projectId]; +} + +export function getDropzoneUploadUrl(files) { + return files[0]?.postData?.bucket ?? ''; +} + +export async function dropzoneAcceptCallback( + projectId, + uploadPath, + file, + done, + dispatch +) { // if a user would want to edit this file as text, local interceptor if (!isS3Upload(file)) { try { @@ -40,30 +104,31 @@ export async function dropzoneAcceptCallback(userId, file, done, dispatch) { console.warn(file); } } else { + if (!projectId) { + done('Please save this sketch before uploading asset files.'); + return; + } try { - const response = await apiClient.post('/S3/sign', { - name: file.name, - type: file.type, - size: file.size, - userId - // _csrf: document.getElementById('__createPostToken').value - }); - // eslint-disable-next-line no-param-reassign - file.postData = response.data; + file.postData = await getUploadPolicy(projectId); + file.uploadPath = uploadPath; done(); } catch (error) { - if (error?.response?.status === 403) { + if (error?.response?.status === 403 || error?.response?.status === 413) { if (dispatch) { dispatch(showErrorModal('uploadLimit')); + dispatch(showToast('Upload limit reached.', 5000)); } done('Upload limit reached.'); return; } - done( - error?.response?.data?.responseText?.message || - error?.message || - 'Error: Reached upload limit.' + const message = getTransactionErrorMessage( + error, + 'Failed to prepare file upload.' ); + if (dispatch) { + dispatch(showToast(message, 5000)); + } + done(message); } } } @@ -71,26 +136,68 @@ export async function dropzoneAcceptCallback(userId, file, done, dispatch) { export function dropzoneSendingCallback(file, xhr, formData) { if (isS3Upload(file)) { Object.keys(file.postData).forEach((key) => { - formData.append(key, file.postData[key]); + if (key !== 'bucket' && file.postData[key] !== undefined) { + formData.append( + key, + key === 'key' + ? buildS3Key(file.postData[key], file.uploadPath) + : file.postData[key] + ); + } }); + formData.append('Content-Type', file.type || ''); } } export function dropzoneCompleteCallback(file) { - return (dispatch) => { + return async (dispatch, getState) => { if (isS3Upload(file) && file.postData && file.status !== 'error') { + const { fileBase } = getState().project; + if (!fileBase) { + showUploadError( + dispatch, + file, + 'Missing OP fileBase; uploaded file URL was not added.' + ); + return; + } const formParams = { name: file.name, - url: `${s3BucketHttps}${file.postData.key}` + url: buildUploadedFileUrl(fileBase, file.uploadPath, file.name) }; - dispatch(handleCreateFile(formParams, false)); + const result = await dispatch( + handleCreateFile(formParams, false, { + overwrite: true, + preserveName: true + }) + ); + if (result?.error) { + showUploadError( + dispatch, + file, + getTransactionErrorMessage( + result.error, + 'Failed to add uploaded file.' + ) + ); + } } else if (file.content !== undefined) { const formParams = { name: file.name, content: file.content }; - dispatch(handleCreateFile(formParams, false)); - } else if (file.status === 'error' || file.xhr.status >= 400) { + const result = await dispatch(handleCreateFile(formParams, false)); + if (result?.error) { + showUploadError( + dispatch, + file, + getTransactionErrorMessage( + result.error, + 'Failed to add uploaded file.' + ) + ); + } + } else if (file.status === 'error' || file.xhr?.status >= 400) { let uploadFileErrorMessage = 'Uploading file to AWS failed.'; if (file.xhr?.response) { const parser = new DOMParser(); @@ -99,14 +206,11 @@ export function dropzoneCompleteCallback(file) { const code = xmlDoc.getElementsByTagName('Code')[0]?.textContent; uploadFileErrorMessage = `${code}: ${message}`; } - file.previewElement.classList.add('dz-error'); - file.previewElement.classList.remove('dz-success'); - const dzErrorMessageElement = file.previewElement?.querySelector( - '[data-dz-errormessage]' - ); - if (dzErrorMessageElement) { - dzErrorMessageElement.textContent = uploadFileErrorMessage; - } + showUploadError(dispatch, file, uploadFileErrorMessage); } }; } + +export function getUploadPathForParent(files, parentId) { + return getUploadPath(files, parentId); +} diff --git a/client/modules/IDE/components/AddToCollectionList.jsx b/client/modules/IDE/components/AddToCollectionList.jsx index 75713367ee..e7460945a8 100644 --- a/client/modules/IDE/components/AddToCollectionList.jsx +++ b/client/modules/IDE/components/AddToCollectionList.jsx @@ -8,6 +8,7 @@ import { Loader } from '../../App/components/Loader'; import { addToCollection, getCollections, + getCollectionIdsForSketch, removeFromCollection } from '../actions/collections'; import getSortedCollections from '../selectors/collections'; @@ -41,22 +42,44 @@ const AddToCollectionList = ({ projectId }) => { const [hasLoadedData, setHasLoadedData] = useState(false); const showLoader = loading && !hasLoadedData; + // Ids of collections that already contain this sketch. Membership comes from + // the sketch's curations rather than each collection's (possibly unloaded) + // items, and is updated optimistically on add/remove. + const [addedCollectionIds, setAddedCollectionIds] = useState(() => new Set()); + useEffect(() => { dispatch(getCollections(username)).then(() => setHasLoadedData(true)); }, [dispatch, username]); + useEffect(() => { + if (!projectId) { + return; + } + dispatch(getCollectionIdsForSketch(projectId)).then((ids) => + setAddedCollectionIds(new Set(ids)) + ); + }, [dispatch, projectId]); + const handleCollectionAdd = (collection) => { - dispatch(addToCollection(collection.id, projectId)); + dispatch(addToCollection(collection.id, projectId)).then(() => + setAddedCollectionIds((prev) => new Set(prev).add(collection.id)) + ); }; const handleCollectionRemove = (collection) => { - dispatch(removeFromCollection(collection.id, projectId)); + dispatch(removeFromCollection(collection.id, projectId)).then(() => + setAddedCollectionIds((prev) => { + const next = new Set(prev); + next.delete(collection.id); + return next; + }) + ); }; const collectionWithSketchStatus = collections.map((collection) => ({ ...collection, url: `/${collection.owner.username}/collections/${collection.id}`, - isAdded: collection.items.some((item) => item.projectId === projectId) + isAdded: addedCollectionIds.has(collection.id) })); const getContent = () => { diff --git a/client/modules/IDE/components/AddToCollectionSketchList.unit.test.jsx b/client/modules/IDE/components/AddToCollectionSketchList.unit.test.jsx index e6650270b7..1de81c8d6e 100644 --- a/client/modules/IDE/components/AddToCollectionSketchList.unit.test.jsx +++ b/client/modules/IDE/components/AddToCollectionSketchList.unit.test.jsx @@ -11,51 +11,64 @@ let requestCount = 0; let addRequestCount = 0; let removeRequestCount = 0; -// helper to create test project data -const makeProjects = (prefix, count) => +// helper to create OP sketch data as returned by GET /user/:userID/sketches +const makeSketches = (prefix, count) => Array.from({ length: count }).map((_, i) => ({ - id: `${prefix}-${i + 1}`, - name: `${prefix}-sketch-${i + 1}`, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - visibility: 'public' + visualID: `${prefix}-${i + 1}`, + title: `${prefix}-sketch-${i + 1}`, + userID: 123456789, + isPrivate: 0, + createdOn: new Date().toISOString() })); const server = setupServer( - rest.get('/projects', (req, res, ctx) => { + rest.get('/user/:userID/sketches', (req, res, ctx) => { requestCount += 1; lastSearchParams = req.url.searchParams; - const page = Number(req.url.searchParams.get('page') ?? 1); const limit = Number(req.url.searchParams.get('limit') ?? 10); + const offset = Number(req.url.searchParams.get('offset') ?? 0); + const page = Math.floor(offset / limit) + 1; - const projects = - page === 1 ? makeProjects('page1', limit) : makeProjects('page2', limit); + const sketches = makeSketches(`page${page}`, limit); + // Total across all pages, surfaced via the X-Total-Count header the + // OP-backed action reads for pagination metadata. return res( ctx.status(200), - ctx.json({ - projects, - metadata: { - page, - totalPages: 6, - totalProjects: 54, - limit, - hasPagination: true - } - }) + ctx.set('X-Total-Count', '54'), + ctx.json(sketches) ); }), - rest.post('/collections/:collectionId/:projectId', (req, res, ctx) => { + rest.post('/curation/:collectionId/sketches/:projectId', (req, res, ctx) => { addRequestCount += 1; - return res(ctx.status(200)); + return res(ctx.status(201)); }), - rest.delete('/collections/:collectionId/:projectId', (req, res, ctx) => { - removeRequestCount += 1; - return res(ctx.status(200)); - }) + // After add/remove the action refetches the curation + its sketches. + rest.get('/curation/:collectionId', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + curationID: req.params.collectionId, + title: 'My Collection', + userID: 1, + username: 'happydog' + }) + ) + ), + rest.get('/curation/:collectionId/sketches', (req, res, ctx) => + res(ctx.status(200), ctx.json([])) + ), + + rest.delete( + '/curation/:collectionId/sketches/:projectId', + (req, res, ctx) => { + removeRequestCount += 1; + return res(ctx.status(200)); + } + ) ); beforeAll(async () => { @@ -92,7 +105,7 @@ describe('', () => { loading}> , - { preloadedState: overrideState ?? initialTestState } + { initialState: overrideState ?? initialTestState } ); it('calls the server on mount with page/limit/q', async () => { @@ -100,7 +113,7 @@ describe('', () => { await screen.findByText('page1-sketch-1'); - expect(lastSearchParams.get('page')).toBe('1'); + expect(lastSearchParams.get('offset')).toBe('0'); expect(lastSearchParams.get('limit')).toBe('10'); const q = lastSearchParams.get('q'); @@ -125,7 +138,7 @@ describe('', () => { await waitFor(() => { expect(requestCount).toBeGreaterThan(before); - expect(lastSearchParams.get('page')).toBe('2'); + expect(lastSearchParams.get('offset')).toBe('10'); }); await screen.findByText('page2-sketch-1'); @@ -142,22 +155,14 @@ describe('', () => { it('shows empty state if there are no projects', async () => { server.use( - rest.get('/projects', (req, res, ctx) => { + rest.get('/user/:userID/sketches', (req, res, ctx) => { requestCount += 1; lastSearchParams = req.url.searchParams; return res( ctx.status(200), - ctx.json({ - projects: [], - metadata: { - page: 1, - totalPages: 1, - totalProjects: 0, - limit: 10, - hasPagination: false - } - }) + ctx.set('X-Total-Count', '0'), + ctx.json([]) ); }) ); @@ -176,7 +181,7 @@ describe('', () => { , - { preloadedState: initialTestState } + { initialState: initialTestState } ); await screen.findByText('page1-sketch-1'); @@ -199,7 +204,7 @@ describe('', () => { items: [{ projectId: 'page1-1', isDeleted: false }] }} />, - { preloadedState: initialTestState } + { initialState: initialTestState } ); await screen.findByText('page1-sketch-1'); @@ -215,30 +220,22 @@ describe('', () => { it('renders correct pagination numbers when totalProjects is not a multiple of 10', async () => { server.use( - rest.get('/projects', (req, res, ctx) => { - const page = Number(req.url.searchParams.get('page') ?? 1); - const limit = 10; + rest.get('/user/:userID/sketches', (req, res, ctx) => { + const limit = Number(req.url.searchParams.get('limit') ?? 10); + const offset = Number(req.url.searchParams.get('offset') ?? 0); + const page = Math.floor(offset / limit) + 1; const totalProjects = 23; - const totalPages = 3; - const start = (page - 1) * limit; + const start = offset; const end = Math.min(start + limit, totalProjects); - const projects = makeProjects(`page${page}`, end - start); + const sketches = makeSketches(`page${page}`, end - start); return res( ctx.status(200), - ctx.json({ - projects, - metadata: { - page, - totalPages, - totalProjects, - limit, - hasPagination: true - } - }) + ctx.set('X-Total-Count', String(totalProjects)), + ctx.json(sketches) ); }) ); @@ -247,7 +244,7 @@ describe('', () => { , - { preloadedState: initialTestState } + { initialState: initialTestState } ); await screen.findByText('page1-sketch-1'); diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx index 7cce30b13f..9fe9b4f508 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -9,15 +9,19 @@ import * as AssetActions from '../actions/assets'; const AssetList = () => { const { t } = useTranslation(); const dispatch = useDispatch(); - const { username, assetList, loading } = useSelector((state) => ({ + const { username, userId, assetList, loading } = useSelector((state) => ({ username: state.user.username, + userId: state.user.id, assetList: state.assets.list, loading: state.loading })); useEffect(() => { + if (!userId) { + return; + } dispatch(AssetActions.getAssets()); - }, []); + }, [dispatch, userId]); const hasAssets = () => !loading && assetList.length > 0; diff --git a/client/modules/IDE/components/AssetListRow.jsx b/client/modules/IDE/components/AssetListRow.jsx index 97340db0c8..3ab1aa6640 100644 --- a/client/modules/IDE/components/AssetListRow.jsx +++ b/client/modules/IDE/components/AssetListRow.jsx @@ -7,6 +7,7 @@ import prettyBytes from 'pretty-bytes'; import { MenuItem } from '../../../components/Dropdown/MenuItem'; import { TableDropdown } from '../../../components/Dropdown/TableDropdown'; import { deleteAssetRequest } from '../actions/assets'; +import { showToast } from '../actions/toast'; const AssetMenu = ({ item: asset }) => { const { t } = useTranslation(); @@ -19,9 +20,21 @@ const AssetMenu = ({ item: asset }) => { } }; + const handleCopyPath = async () => { + try { + await navigator.clipboard.writeText(asset.url); + dispatch(showToast(t('AssetList.CopyPathSuccess'))); + } catch (_e) { + dispatch(showToast(t('AssetList.CopyPathFailure'), 5000)); + } + }; + return ( - {t('AssetList.Delete')} + {asset.visualID && ( + {t('AssetList.Delete')} + )} + {t('AssetList.CopyPath')} {t('AssetList.OpenNewTab')} @@ -33,7 +46,8 @@ AssetMenu.propTypes = { item: PropTypes.shape({ key: PropTypes.string.isRequired, url: PropTypes.string.isRequired, - name: PropTypes.string.isRequired + name: PropTypes.string.isRequired, + visualID: PropTypes.string }).isRequired }; @@ -62,6 +76,7 @@ AssetListRow.propTypes = { asset: PropTypes.shape({ key: PropTypes.string.isRequired, url: PropTypes.string.isRequired, + visualID: PropTypes.string, sketchId: PropTypes.string, sketchName: PropTypes.string, name: PropTypes.string.isRequired, diff --git a/client/modules/IDE/components/CollectionList/CollectionList.jsx b/client/modules/IDE/components/CollectionList/CollectionList.jsx index 9a59c02d8a..5d8a416cae 100644 --- a/client/modules/IDE/components/CollectionList/CollectionList.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionList.jsx @@ -26,6 +26,7 @@ const CollectionList = ({ user, projectId, getCollections, + getCollection, getProject, collections, username: propsUsername, @@ -67,6 +68,8 @@ const CollectionList = ({ }, [propsUsername, user.username, t]); const showAddSketches = (collectionId) => { + // Load the collection's sketches so the overlay can show which are already added. + getCollection(collectionId); setAddingSketchesToCollectionId(collectionId); }; @@ -224,6 +227,7 @@ CollectionList.propTypes = { }).isRequired, projectId: PropTypes.string, getCollections: PropTypes.func.isRequired, + getCollection: PropTypes.func.isRequired, getProject: PropTypes.func.isRequired, collections: PropTypes.arrayOf( PropTypes.shape({ diff --git a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx index 6ae24e74e9..703418e5e7 100644 --- a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx @@ -221,7 +221,7 @@ const CollectionListRowBase = (props) => { {formatDateCell(collection.updatedAt, mobile)} {mobile && 'sketches: '} - {(collection.items || []).length} + {collection.numItems ?? (collection.items || []).length} {renderActions()} @@ -237,6 +237,7 @@ CollectionListRowBase.propTypes = { }).isRequired, createdAt: PropTypes.string.isRequired, updatedAt: PropTypes.string.isRequired, + numItems: PropTypes.number, items: PropTypes.arrayOf( PropTypes.shape({ project: PropTypes.shape({ diff --git a/client/modules/IDE/components/FileNode.jsx b/client/modules/IDE/components/FileNode.jsx index 2a381624ef..a4895f644a 100644 --- a/client/modules/IDE/components/FileNode.jsx +++ b/client/modules/IDE/components/FileNode.jsx @@ -1,12 +1,13 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import React, { useState, useRef } from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import * as IDEActions from '../actions/ide'; import * as FileActions from '../actions/files'; +import { showToast } from '../actions/toast'; import DownArrowIcon from '../../../images/down-filled-triangle.svg'; import FolderRightIcon from '../../../images/triangle-arrow-right.svg'; import FolderDownIcon from '../../../images/triangle-arrow-down.svg'; @@ -69,6 +70,7 @@ const FileNode = ({ children, name, fileType, + url, isSelectedFile, isFolderClosed, setSelectedFile, @@ -108,6 +110,7 @@ const FileNode = ({ }; const { t } = useTranslation(); + const dispatch = useDispatch(); const fileNameInput = useRef(null); const fileOptionsRef = useRef(null); @@ -155,6 +158,16 @@ const FileNode = ({ setTimeout(hideFileOptions, 0); }; + const handleClickCopyPath = async () => { + try { + await navigator.clipboard.writeText(url); + dispatch(showToast(t('FileNode.CopyPathSuccess'))); + } catch (_e) { + dispatch(showToast(t('FileNode.CopyPathFailure'), 5000)); + } + setTimeout(() => hideFileOptions(), 0); + }; + const handleClickDelete = () => { const prompt = t('Common.DeleteConfirmation', { name }); @@ -363,6 +376,16 @@ const FileNode = ({ {t('FileNode.Rename')} + {isFile && url && ( +
  • + +
  • + )}
  • - - - {keyWithToken && ( -
    -

    - {t('APIKeyForm.NewTokenTitle')} -

    -

    - {t('APIKeyForm.NewTokenInfo')} -

    - -
    - )} - - -
    -

    - {t('APIKeyForm.ExistingTokensTitle')} -

    - {renderApiKeys()} -
    - - ); -}; diff --git a/client/modules/User/components/APIKeyList.tsx b/client/modules/User/components/APIKeyList.tsx deleted file mode 100644 index a3692fad32..0000000000 --- a/client/modules/User/components/APIKeyList.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import { orderBy } from 'lodash'; -import { useTranslation } from 'react-i18next'; -import type { SanitisedApiKey } from '../../../../common/types'; -import { - distanceInWordsToNow, - formatDateToString -} from '../../../utils/formatDate'; -import TrashCanIcon from '../../../images/trash-can.svg'; - -export interface APIKeyListProps { - apiKeys: SanitisedApiKey[]; - onRemove: (key: SanitisedApiKey) => void; -} -export function APIKeyList({ apiKeys, onRemove }: APIKeyListProps) { - const { t } = useTranslation(); - return ( - - - - - - - - - - - {orderBy(apiKeys, ['createdAt'], ['desc']).map((key) => { - const lastUsed = key.lastUsedAt - ? distanceInWordsToNow(new Date(key.lastUsedAt)) - : t('APIKeyList.Never'); - - return ( - - - - - - - ); - })} - -
    {t('APIKeyList.Name')}{t('APIKeyList.Created')}{t('APIKeyList.LastUsed')}{t('APIKeyList.Actions')}
    {key.label}{formatDateToString(key.createdAt)}{lastUsed} - -
    - ); -} diff --git a/client/modules/User/components/AccountForm.tsx b/client/modules/User/components/AccountForm.tsx deleted file mode 100644 index e8e3737d9b..0000000000 --- a/client/modules/User/components/AccountForm.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React from 'react'; -import { Form, Field } from 'react-final-form'; -import { useSelector, useDispatch } from 'react-redux'; -import { useTranslation } from 'react-i18next'; -import { Button, ButtonTypes } from '../../../common/Button'; -import { validateSettings } from '../../../utils/reduxFormUtils'; -import { updateSettings, initiateVerification } from '../actions'; -import { apiClient } from '../../../utils/apiClient'; -import { - DuplicateUserCheckQuery, - UpdateSettingsRequestBody -} from '../../../../common/types'; -import { RootState } from '../../../reducers'; -import type { AccountForm as AccountFormType } from '../../../utils/reduxFormUtils'; - -function asyncValidate(fieldToValidate: 'username' | 'email', value: string) { - if (!value || value.trim().length === 0) { - return ''; - } - const queryParams: DuplicateUserCheckQuery = { - check_type: fieldToValidate - }; - queryParams[fieldToValidate] = value; - return apiClient - .get('/signup/duplicate_check', { params: queryParams }) - .then((response) => { - if (response.data.exists) { - return response.data.message; - } - return ''; - }); -} - -export function AccountForm() { - const { t } = useTranslation(); - const user = useSelector((state: RootState) => state.user); - const dispatch = useDispatch(); - - const handleInitiateVerification = (evt: React.MouseEvent) => { - evt.preventDefault(); - dispatch(initiateVerification()); - }; - - function validateUsername(username: AccountFormType['username']) { - if (username === user.username) return ''; - return asyncValidate('username', username); - } - - function validateEmail(email: AccountFormType['email']) { - if (email === user.email) return ''; - return asyncValidate('email', email); - } - - function onSubmit(formProps: UpdateSettingsRequestBody) { - return dispatch(updateSettings(formProps)); - } - - return ( -
    - {({ handleSubmit, submitting, invalid, form }) => ( - { - const result = await handleSubmit(event); - form.restart(); - return result; - }} - > - - {(field) => ( -

    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -

    - )} -
    - {user.verified !== 'verified' && ( -

    - - {t('AccountForm.Unconfirmed')} - - {user.emailVerificationInitiate === true ? ( - - {' '} - {t('AccountForm.EmailSent')} - - ) : ( - - )} -

    - )} - - {(field) => ( -

    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -

    - )} -
    - {user.github === undefined && user.google === undefined && ( - - {(field) => ( -

    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -

    - )} -
    - )} - {user.github === undefined && user.google === undefined && ( - - {(field) => ( -

    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -

    - )} -
    - )} - -
    - )} - - ); -} diff --git a/client/modules/User/components/AccountForm.unit.test.jsx b/client/modules/User/components/AccountForm.unit.test.jsx deleted file mode 100644 index b9d2baebe0..0000000000 --- a/client/modules/User/components/AccountForm.unit.test.jsx +++ /dev/null @@ -1,122 +0,0 @@ -import React from 'react'; -import thunk from 'redux-thunk'; -import configureStore from 'redux-mock-store'; -import { - reduxRender, - screen, - fireEvent, - act, - waitFor -} from '../../../test-utils'; -import { AccountForm } from './AccountForm'; -import { initialTestState } from '../../../testData/testReduxStore'; -import * as actions from '../actions'; - -const mockStore = configureStore([thunk]); -const store = mockStore(initialTestState); - -jest.mock('../actions', () => ({ - ...jest.requireActual('../actions'), - updateSettings: jest.fn().mockReturnValue( - (dispatch) => - new Promise((resolve) => { - setTimeout(() => { - dispatch({ type: 'UPDATE_SETTINGS', payload: {} }); - resolve(); - }, 100); - }) - ) -})); - -const subject = () => { - reduxRender(, { - store - }); -}; - -describe('', () => { - it('renders form fields with initial values', () => { - subject(); - const emailElement = screen.getByRole('textbox', { - name: /email/i - }); - expect(emailElement).toBeInTheDocument(); - expect(emailElement).toHaveValue('happydog@example.com'); - - const userNameElement = screen.getByRole('textbox', { - name: /username/i - }); - expect(userNameElement).toBeInTheDocument(); - expect(userNameElement).toHaveValue('happydog'); - - const currentPasswordElement = screen.getByLabelText(/current password/i); - expect(currentPasswordElement).toBeInTheDocument(); - expect(currentPasswordElement).toHaveValue(''); - - const newPasswordElement = screen.getByLabelText(/new password/i); - expect(newPasswordElement).toBeInTheDocument(); - expect(newPasswordElement).toHaveValue(''); - }); - - it('handles form submission and calls updateSettings', async () => { - subject(); - - const saveAccountDetailsButton = screen.getByRole('button', { - name: /save account details/i - }); - - const currentPasswordElement = screen.getByLabelText(/current password/i); - const newPasswordElement = screen.getByLabelText(/new password/i); - - fireEvent.change(currentPasswordElement, { - target: { - value: 'currentPassword' - } - }); - - fireEvent.change(newPasswordElement, { - target: { - value: 'newPassword' - } - }); - - await act(async () => { - fireEvent.click(saveAccountDetailsButton); - }); - - await waitFor(() => { - expect(actions.updateSettings).toHaveBeenCalledTimes(1); - }); - }); - - it('Save all setting button should get disabled while submitting and enable when not submitting', async () => { - subject(); - - const saveAccountDetailsButton = screen.getByRole('button', { - name: /save account details/i - }); - - const currentPasswordElement = screen.getByLabelText(/current password/i); - const newPasswordElement = screen.getByLabelText(/new password/i); - - fireEvent.change(currentPasswordElement, { - target: { - value: 'currentPassword' - } - }); - - fireEvent.change(newPasswordElement, { - target: { - value: 'newPassword' - } - }); - expect(saveAccountDetailsButton).not.toHaveAttribute('disabled'); - - await act(async () => { - fireEvent.click(saveAccountDetailsButton); - await waitFor(() => { - expect(saveAccountDetailsButton).toHaveAttribute('disabled'); - }); - }); - }); -}); diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx index ceba6c0606..c84d6b2d25 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -23,9 +23,9 @@ const Collection = ({ collectionId, username }) => { const loading = useSelector((state) => state.loading); useEffect(() => { - dispatch(CollectionsActions.getCollections(username)); + dispatch(CollectionsActions.getCollection(collectionId)); dispatch(SortingActions.resetSorting()); - }, [dispatch, username]); + }, [dispatch, collectionId]); const isOwner = () => user != null && diff --git a/client/modules/User/components/CookieConsent.tsx b/client/modules/User/components/CookieConsent.tsx index dd470796dc..74f3b474ad 100644 --- a/client/modules/User/components/CookieConsent.tsx +++ b/client/modules/User/components/CookieConsent.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; import Cookies from 'js-cookie'; import styled from 'styled-components'; import ReactGA from 'react-ga'; @@ -7,10 +6,8 @@ import { Transition } from 'react-transition-group'; import { Link } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { getConfig } from '../../../utils/getConfig'; -import { setUserCookieConsent } from '../actions'; import { remSize, prop, device } from '../../../theme'; import { Button, ButtonKinds } from '../../../common/Button'; -import { RootState } from '../../../reducers'; import { CookieConsentOptions } from '../../../../common/types'; interface CookieConsentContainerState { @@ -82,85 +79,53 @@ const CookieConsentButtons = styled.div` `; const GOOGLE_ANALYTICS_ID = getConfig('GA_MEASUREMENT_ID'); +const COOKIE_CONSENT_KEY = 'p5-cookie-consent'; + +function readStoredCookieConsent(): CookieConsentOptions { + const storedValue = + typeof localStorage !== 'undefined' + ? localStorage.getItem(COOKIE_CONSENT_KEY) + : null; + if ( + storedValue && + Object.values(CookieConsentOptions).includes( + storedValue as CookieConsentOptions + ) + ) { + return storedValue as CookieConsentOptions; + } + + return CookieConsentOptions.NONE; +} export function CookieConsent({ hide = false }: { hide?: boolean }) { - const user = useSelector((state: RootState) => state.user); const [ cookieConsent, setBrowserCookieConsent ] = useState(CookieConsentOptions.NONE); const [inProp, setInProp] = useState(false); - const dispatch = useDispatch(); const { t } = useTranslation(); - function initializeCookieConsent() { - if (user.authenticated) { - if (!user.cookieConsent) { - return; - } - setBrowserCookieConsent(user.cookieConsent); - Cookies.set('p5-cookie-consent', user.cookieConsent, { expires: 365 }); - return; - } - setBrowserCookieConsent(CookieConsentOptions.NONE); - Cookies.set('p5-cookie-consent', CookieConsentOptions.NONE, { - expires: 365 - }); - } - function acceptAllCookies() { - if (user.authenticated) { - dispatch(setUserCookieConsent(CookieConsentOptions.ALL)); - } setBrowserCookieConsent(CookieConsentOptions.ALL); - Cookies.set('p5-cookie-consent', CookieConsentOptions.ALL, { - expires: 365 - }); + localStorage.setItem(COOKIE_CONSENT_KEY, CookieConsentOptions.ALL); } function acceptEssentialCookies() { - if (user.authenticated) { - dispatch(setUserCookieConsent(CookieConsentOptions.ESSENTIAL)); - } setBrowserCookieConsent(CookieConsentOptions.ESSENTIAL); - Cookies.set('p5-cookie-consent', CookieConsentOptions.ESSENTIAL, { - expires: 365 - }); - // Remove Google Analytics Cookies + localStorage.setItem(COOKIE_CONSENT_KEY, CookieConsentOptions.ESSENTIAL); + // Remove any existing Google Analytics tracking cookies Cookies.remove('_ga'); Cookies.remove('_gat'); Cookies.remove('_gid'); } - function mergeCookieConsent() { - if (user.authenticated) { - if (!user.cookieConsent) { - user.cookieConsent = CookieConsentOptions.NONE; - } - if ( - user.cookieConsent === CookieConsentOptions.NONE && - cookieConsent !== CookieConsentOptions.NONE - ) { - dispatch(setUserCookieConsent(cookieConsent as CookieConsentOptions)); - } else if (user.cookieConsent !== CookieConsentOptions.NONE) { - setBrowserCookieConsent(user.cookieConsent); - Cookies.set('p5-cookie-consent', user.cookieConsent, { - expires: 365 - }); - } - } - } - useEffect(() => { - const p5CookieConsent = Cookies.get('p5-cookie-consent'); - if (p5CookieConsent) { - setBrowserCookieConsent(p5CookieConsent as CookieConsentOptions); - } else { - initializeCookieConsent(); - } + const p5CookieConsent = readStoredCookieConsent(); + setBrowserCookieConsent(p5CookieConsent); if (GOOGLE_ANALYTICS_ID) { - if (p5CookieConsent === 'essential') { + if (p5CookieConsent === CookieConsentOptions.ESSENTIAL) { ReactGA.initialize(GOOGLE_ANALYTICS_ID, { gaOptions: { storage: 'none' @@ -173,10 +138,6 @@ export function CookieConsent({ hide = false }: { hide?: boolean }) { } }, []); - useEffect(() => { - mergeCookieConsent(); - }, [user.authenticated]); - useEffect(() => { if (cookieConsent !== 'none') { setInProp(false); diff --git a/client/modules/User/components/LoginForm.tsx b/client/modules/User/components/LoginForm.tsx deleted file mode 100644 index 97da0fc78d..0000000000 --- a/client/modules/User/components/LoginForm.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React, { useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Form, Field } from 'react-final-form'; -import { useDispatch } from 'react-redux'; -import { AiOutlineEye, AiOutlineEyeInvisible } from 'react-icons/ai'; -import { Button, ButtonTypes } from '../../../common/Button'; -import { validateLogin } from '../../../utils/reduxFormUtils'; -import { validateAndLoginUser } from '../actions'; -import { - FormLike, - useSyncFormTranslations -} from '../../../common/useSyncFormTranslations'; -import type { LoginForm as LoginFormType } from '../../../utils/reduxFormUtils'; - -export function LoginForm() { - const { t, i18n } = useTranslation(); - - const dispatch = useDispatch(); - function onSubmit(formProps: LoginFormType) { - return dispatch(validateAndLoginUser(formProps)); - } - const [showPassword, setShowPassword] = useState(false); - const formRef = useRef | null>(null); - - const handleVisibility = () => { - setShowPassword(!showPassword); - }; - - useSyncFormTranslations(formRef, i18n.language); - - return ( -
    - {({ - handleSubmit, - submitError, - submitting, - modifiedSinceLastSubmit, - form - }) => { - formRef.current = form; - - return ( - - - {(field) => ( -
    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -
    - )} -
    - - {(field) => ( -
    - -
    - - -
    - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -
    - )} -
    - {submitError && !modifiedSinceLastSubmit && ( - - {t('LoginForm.Errors.invalidCredentials')} - - )} - -
    - ); - }} - - ); -} diff --git a/client/modules/User/components/LoginForm.unit.test.jsx b/client/modules/User/components/LoginForm.unit.test.jsx deleted file mode 100644 index b3427ead10..0000000000 --- a/client/modules/User/components/LoginForm.unit.test.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import thunk from 'redux-thunk'; -import configureStore from 'redux-mock-store'; -import { LoginForm } from './LoginForm'; -import * as actions from '../actions'; -import { initialTestState } from '../../../testData/testReduxStore'; -import { reduxRender, screen, fireEvent, act } from '../../../test-utils'; - -const mockStore = configureStore([thunk]); -const store = mockStore(initialTestState); - -jest.mock('../actions', () => ({ - ...jest.requireActual('../actions'), - validateAndLoginUser: jest.fn().mockReturnValue( - (dispatch) => - new Promise((resolve) => { - setTimeout(() => { - dispatch({ type: 'AUTH_USER', payload: {} }); - dispatch({ type: 'SET_PREFERENCES', payload: {} }); - resolve(); - }, 100); - }) - ) -})); - -jest.mock('../../../common/useSyncFormTranslations', () => ({ - useSyncFormTranslations: jest.fn() -})); - -const subject = () => { - reduxRender(, { - store - }); -}; - -describe('', () => { - test('Renders form with the correct fields.', () => { - subject(); - const emailTextElement = screen.getByText(/email or username/i); - expect(emailTextElement).toBeInTheDocument(); - - const emailInputElement = screen.getByRole('textbox', { - name: /email or username/i - }); - expect(emailInputElement).toBeInTheDocument(); - - const passwordTextElement = screen.getByText(/password/i); - expect(passwordTextElement).toBeInTheDocument(); - - const passwordInputElement = screen.getByLabelText(/^password$/i); - expect(passwordInputElement).toBeInTheDocument(); - - const loginButtonElement = screen.getByRole('button', { - name: /log in/i - }); - expect(loginButtonElement).toBeInTheDocument(); - }); - test('Validate and login user is called with the correct values.', async () => { - subject(); - - const emailElement = screen.getByRole('textbox', { - name: /email or username/i - }); - fireEvent.change(emailElement, { - target: { - value: 'correctuser@gmail.com' - } - }); - - const passwordElement = screen.getByLabelText(/^password$/i); - - fireEvent.change(passwordElement, { - target: { - value: 'correctpassword' - } - }); - - const loginButton = screen.getByRole('button', { - name: /log in/i - }); - await act(async () => { - fireEvent.click(loginButton); - }); - - expect(actions.validateAndLoginUser).toHaveBeenCalledWith({ - email: 'correctuser@gmail.com', - password: 'correctpassword' - }); - }); -}); diff --git a/client/modules/User/components/NewPasswordForm.tsx b/client/modules/User/components/NewPasswordForm.tsx deleted file mode 100644 index b4e1618e29..0000000000 --- a/client/modules/User/components/NewPasswordForm.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import { Form, Field } from 'react-final-form'; -import { useDispatch } from 'react-redux'; -import { useTranslation } from 'react-i18next'; -import { validateNewPassword } from '../../../utils/reduxFormUtils'; -import { updatePassword } from '../actions'; -import { Button, ButtonTypes } from '../../../common/Button'; -import type { NewPasswordForm as NewPasswordFormType } from '../../../utils/reduxFormUtils'; - -export function NewPasswordForm(props: { resetPasswordToken: string }) { - const { resetPasswordToken } = props; - const { t } = useTranslation(); - const dispatch = useDispatch(); - - function onSubmit(formProps: NewPasswordFormType) { - return dispatch(updatePassword(formProps, resetPasswordToken)); - } - - return ( -
    - {({ handleSubmit, submitting, invalid, pristine }) => ( - - - {(field) => ( -

    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -

    - )} -
    - - {(field) => ( -

    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -

    - )} -
    - -
    - )} - - ); -} diff --git a/client/modules/User/components/NewPasswordForm.unit.test.jsx b/client/modules/User/components/NewPasswordForm.unit.test.jsx deleted file mode 100644 index a55af924ed..0000000000 --- a/client/modules/User/components/NewPasswordForm.unit.test.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import thunk from 'redux-thunk'; -import configureStore from 'redux-mock-store'; -import { fireEvent } from '@storybook/testing-library'; -import { reduxRender, screen, act, waitFor } from '../../../test-utils'; -import { initialTestState } from '../../../testData/testReduxStore'; -import { NewPasswordForm } from './NewPasswordForm'; - -const mockStore = configureStore([thunk]); -const store = mockStore(initialTestState); - -const mockResetPasswordToken = 'mockResetToken'; -const subject = () => { - reduxRender(, { - store - }); -}; -const mockDispatch = jest.fn(); - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: () => mockDispatch -})); - -jest.mock('../../../utils/reduxFormUtils', () => ({ - validateNewPassword: jest.fn() -})); - -jest.mock('../actions', () => ({ - updatePassword: jest.fn().mockReturnValue( - new Promise((resolve) => { - resolve(); - }) - ) -})); - -describe('', () => { - beforeEach(() => { - mockDispatch.mockClear(); - jest.clearAllMocks(); - }); - test('renders form fields correctly', () => { - subject(); - - const passwordInputElements = screen.getAllByLabelText(/Password/i); - expect(passwordInputElements).toHaveLength(2); - - const passwordInputElement = passwordInputElements[0]; - expect(passwordInputElement).toBeInTheDocument(); - - const confirmPasswordInputElement = passwordInputElements[1]; - expect(confirmPasswordInputElement).toBeInTheDocument(); - - const submitElemement = screen.getByRole('button', { - name: /set new password/i - }); - expect(submitElemement).toBeInTheDocument(); - }); - - test('submits form with valid data', async () => { - subject(); - const passwordInputElements = screen.getAllByLabelText(/Password/i); - - const passwordInputElement = passwordInputElements[0]; - fireEvent.change(passwordInputElement, { - target: { value: 'password123' } - }); - - const confirmPasswordInputElement = passwordInputElements[1]; - fireEvent.change(confirmPasswordInputElement, { - target: { value: 'password123' } - }); - - const submitElemement = screen.getByRole('button', { - name: /set new password/i - }); - - await act(async () => { - fireEvent.click(submitElemement); - }); - - await waitFor(() => expect(mockDispatch).toHaveBeenCalled()); - }); -}); diff --git a/client/modules/User/components/OpenProcessingButton.tsx b/client/modules/User/components/OpenProcessingButton.tsx new file mode 100644 index 0000000000..b27fa02ec2 --- /dev/null +++ b/client/modules/User/components/OpenProcessingButton.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; +import { Button } from '../../../common/Button'; +import browserHistory from '../../../browserHistory'; +import { remSize } from '../../../theme'; +import { + buildAuthorizeUrl, + clearPkce, + exchangeCodeForToken, + loadPkce, + makePkce, + savePkce, + setStoredToken +} from '../../../utils/opAuth'; +import { getUser } from '../actions'; + +const StyledButton = styled(Button)` + width: ${remSize(300)}; +`; + +const STATUS_COLORS = { + error: '#c00', + success: '#2a7', + info: '#666' +} as const; + +type StatusKind = keyof typeof STATUS_COLORS; + +const StatusLine = styled.p<{ $kind: StatusKind }>` + width: ${remSize(300)}; + margin: ${remSize(8)} auto 0; + font-size: ${remSize(12)}; + color: ${(p) => STATUS_COLORS[p.$kind]}; + text-align: center; +`; + +interface OpMessage { + type: 'op-oauth'; + code?: string | null; + state?: string | null; + error?: string | null; + errorDescription?: string | null; +} + +const EDITOR_ORIGIN = + typeof window !== 'undefined' ? window.location.origin : ''; + +export function OpenProcessingButton() { + const dispatch = useDispatch(); + const [status, setStatus] = useState<{ + kind: StatusKind; + text: string; + } | null>(null); + const popupRef = useRef(null); + + useEffect(() => { + function onMessage(event: MessageEvent) { + if (event.origin !== EDITOR_ORIGIN) return; + const data = event.data as OpMessage | undefined; + if (!data || data.type !== 'op-oauth') return; + + if (data.error) { + setStatus({ + kind: 'error', + text: `OP error: ${data.error}${ + data.errorDescription ? ` — ${data.errorDescription}` : '' + }` + }); + clearPkce(); + return; + } + + const pkce = loadPkce(); + if (!pkce || !data.code || data.state !== pkce.state) { + setStatus({ kind: 'error', text: 'State mismatch or missing code.' }); + clearPkce(); + return; + } + + setStatus({ kind: 'info', text: 'Exchanging code…' }); + + exchangeCodeForToken(data.code, pkce.codeVerifier) + .then(async ({ accessToken }) => { + setStoredToken(accessToken); + clearPkce(); + setStatus({ kind: 'info', text: 'Loading your profile…' }); + // Hydrate redux state.user from /api/whoami using the new token, + // then take the user back into the editor. + await dispatch(getUser() as any); + browserHistory.push('/'); + }) + .catch((e: Error) => { + clearPkce(); + setStatus({ kind: 'error', text: `Exchange error: ${e.message}` }); + }); + } + + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [dispatch]); + + async function onClick() { + setStatus({ kind: 'info', text: 'Opening OpenProcessing…' }); + try { + const pkce = await makePkce(); + savePkce({ state: pkce.state, codeVerifier: pkce.codeVerifier }); + const popupUrl = buildAuthorizeUrl(pkce); + + const w = 520; + const h = 720; + const left = window.screenX + (window.outerWidth - w) / 2; + const top = window.screenY + (window.outerHeight - h) / 2; + popupRef.current = window.open( + popupUrl, + 'opLogin', + `width=${w},height=${h},left=${left},top=${top}` + ); + if (!popupRef.current) { + setStatus({ + kind: 'error', + text: 'Popup blocked. Allow popups for this site and try again.' + }); + } + } catch (e) { + setStatus({ + kind: 'error', + text: `Init failed: ${(e as Error).message}` + }); + } + } + + return ( + <> + + Continue with OpenProcessing + + {status && {status.text}} + + ); +} diff --git a/client/modules/User/components/ResetPasswordForm.tsx b/client/modules/User/components/ResetPasswordForm.tsx deleted file mode 100644 index f68c69d134..0000000000 --- a/client/modules/User/components/ResetPasswordForm.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Form, Field } from 'react-final-form'; -import { useDispatch, useSelector } from 'react-redux'; -import { validateResetPassword } from '../../../utils/reduxFormUtils'; -import { initiateResetPassword } from '../actions'; -import { Button, ButtonTypes } from '../../../common/Button'; -import { RootState } from '../../../reducers'; -import { ResetPasswordInitiateRequestBody } from '../../../../common/types'; - -export function ResetPasswordForm() { - const { t } = useTranslation(); - const resetPasswordInitiate = useSelector( - (state: RootState) => state.user.resetPasswordInitiate - ); - const dispatch = useDispatch(); - - function onSubmit(formProps: ResetPasswordInitiateRequestBody) { - dispatch(initiateResetPassword(formProps)); - } - - return ( -
    - {({ handleSubmit, submitting, pristine, invalid }) => ( - - - {(field) => ( -

    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -

    - )} -
    - -
    - )} - - ); -} diff --git a/client/modules/User/components/SignupForm.tsx b/client/modules/User/components/SignupForm.tsx deleted file mode 100644 index 73c09d7e3b..0000000000 --- a/client/modules/User/components/SignupForm.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import React, { useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Form, Field } from 'react-final-form'; -import { useDispatch } from 'react-redux'; -import { AiOutlineEye, AiOutlineEyeInvisible } from 'react-icons/ai'; -import { validateSignup } from '../../../utils/reduxFormUtils'; -import { validateAndSignUpUser } from '../actions'; -import { Button, ButtonTypes } from '../../../common/Button'; -import { apiClient } from '../../../utils/apiClient'; -import { - FormLike, - useSyncFormTranslations -} from '../../../common/useSyncFormTranslations'; -import { - CreateUserRequestBody, - DuplicateUserCheckQuery -} from '../../../../common/types'; - -const timeoutRef: { current: (() => void) | null } = { current: null }; - -function asyncValidate(fieldToValidate: 'username' | 'email', value: string) { - if (!value || value.trim().length === 0) { - return Promise.resolve(''); - } - - const queryParams: DuplicateUserCheckQuery = { - [fieldToValidate]: value, - check_type: fieldToValidate - }; - - return new Promise((resolve) => { - if (timeoutRef.current) { - timeoutRef.current(); - } - - const timerId = setTimeout(() => { - apiClient - .get('/signup/duplicate_check', { params: queryParams }) - .then((response) => { - if (response.data.exists) { - resolve(response.data.message); - } else { - resolve(''); - } - }) - .catch(() => { - resolve('An error occurred while validating'); - }); - }, 300); - - timeoutRef.current = () => { - clearTimeout(timerId); - resolve(''); - }; - }); -} - -function validateUsername(username: CreateUserRequestBody['username']) { - return asyncValidate('username', username); -} - -function validateEmail(email: CreateUserRequestBody['email']) { - return asyncValidate('email', email); -} - -export function SignupForm() { - const { t, i18n } = useTranslation(); - const dispatch = useDispatch(); - const formRef = useRef | null>(null); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - - useSyncFormTranslations(formRef, i18n.language); - - const handleVisibility = () => setShowPassword(!showPassword); - const handleConfirmVisibility = () => - setShowConfirmPassword(!showConfirmPassword); - - function onSubmit(formProps: CreateUserRequestBody) { - return dispatch(validateAndSignUpUser(formProps)); - } - - return ( -
    - {({ handleSubmit, pristine, submitting, invalid, form }) => { - formRef.current = form; - return ( - - - {(field) => ( -
    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -
    - )} -
    - - {(field) => ( -
    - - - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -
    - )} -
    - - {(field) => ( -
    - -
    - - -
    - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -
    - )} -
    - - {(field) => ( -
    - -
    - - -
    - {field.meta.touched && field.meta.error && ( - - {field.meta.error} - - )} -
    - )} -
    - -
    - ); - }} - - ); -} diff --git a/client/modules/User/components/SocialAuthButton.stories.jsx b/client/modules/User/components/SocialAuthButton.stories.jsx deleted file mode 100644 index 5bd25dc16a..0000000000 --- a/client/modules/User/components/SocialAuthButton.stories.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -import { SocialAuthButton, SocialAuthServices } from './SocialAuthButton'; - -export default { - title: 'User/components/SocialAuthButton', - component: SocialAuthButton -}; - -export const Github = () => ( - - Log in with Github - -); - -export const Google = () => ( - - Sign up with Google - -); diff --git a/client/modules/User/components/SocialAuthButton.tsx b/client/modules/User/components/SocialAuthButton.tsx deleted file mode 100644 index b728168b94..0000000000 --- a/client/modules/User/components/SocialAuthButton.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { useTranslation } from 'react-i18next'; -import { useDispatch } from 'react-redux'; - -import { remSize } from '../../../theme'; -import { GithubIcon, GoogleIcon } from '../../../common/icons'; -import { Button } from '../../../common/Button'; -import { unlinkService } from '../actions'; -import { persistState } from '../../IDE/actions/project'; - -const authUrls = { - github: '/auth/github', - google: '/auth/google' -}; - -const icons = { - github: GithubIcon, - google: GoogleIcon -}; - -export enum SocialAuthServices { - github = 'github', - google = 'google' -} - -const servicesLabels = { - github: 'GitHub', - google: 'Google' -}; - -const StyledButton = styled(Button)` - width: ${remSize(300)}; -`; - -export interface SocialAuthButtonProps { - service: SocialAuthServices; - linkStyle?: boolean; - isConnected?: boolean; -} -export function SocialAuthButton({ - service, - linkStyle = false, - isConnected = false -}: SocialAuthButtonProps) { - const { t } = useTranslation(); - - const ServiceIcon = icons[service]; - const serviceLabel = servicesLabels[service]; - const loginLabel = t('SocialAuthButton.Login', { serviceauth: serviceLabel }); - const connectLabel = t('SocialAuthButton.Connect', { - serviceauth: serviceLabel - }); - const unlinkLabel = t('SocialAuthButton.Unlink', { - serviceauth: serviceLabel - }); - const ariaLabel = t('SocialAuthButton.LogoARIA', { serviceauth: service }); - const dispatch = useDispatch(); - if (linkStyle) { - if (isConnected) { - return ( - } - onClick={() => { - dispatch(unlinkService(service)); - }} - > - {unlinkLabel} - - ); - } - return ( - } - href={authUrls[service]} - onClick={() => dispatch(persistState())} - > - {connectLabel} - - ); - } - return ( - } - href={authUrls[service]} - onClick={() => dispatch(persistState())} - > - {loginLabel} - - ); -} diff --git a/client/modules/User/pages/AccountView.tsx b/client/modules/User/pages/AccountView.tsx deleted file mode 100644 index dfe4b548ab..0000000000 --- a/client/modules/User/pages/AccountView.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; -import { Helmet } from 'react-helmet'; -import { useTranslation } from 'react-i18next'; -import { useHistory, useLocation } from 'react-router-dom'; -import { parse } from 'query-string'; -import { AccountForm } from '../components/AccountForm'; -import { - SocialAuthButton, - SocialAuthServices -} from '../components/SocialAuthButton'; -import { APIKeyForm } from '../components/APIKeyForm'; -import Nav from '../../IDE/components/Header/Nav'; -import ErrorModal from '../../IDE/components/ErrorModal'; -import { hideErrorModal } from '../../IDE/actions/ide'; -import { Overlay } from '../../App/components/Overlay'; -import Toast from '../../IDE/components/Toast'; -import { RootState } from '../../../reducers'; -import { resetPasswordReset } from '../actions'; - -function SocialLoginPanel() { - const { t } = useTranslation(); - const isGithub = useSelector((state: RootState) => !!state.user.github); - const isGoogle = useSelector((state: RootState) => !!state.user.google); - return ( - - -

    - {t('AccountView.SocialLogin')} -

    -

    - {t('AccountView.SocialLoginDescription')} -

    -
    - - -
    -
    - ); -} - -export function AccountView() { - const { t } = useTranslation(); - const dispatch = useDispatch(); - - useEffect(() => { - dispatch(resetPasswordReset()); - }, [dispatch]); - const location = useLocation(); - const queryParams = parse(location.search); - const showError = !!queryParams.error; - const errorType = queryParams.error as string; - const accessTokensUIEnabled = window.process.env.UI_ACCESS_TOKEN_ENABLED; - const history = useHistory(); - - return ( -
    - - {t('AccountView.Title')} - - - -
    - ); -} diff --git a/client/modules/User/pages/EmailVerificationView.tsx b/client/modules/User/pages/EmailVerificationView.tsx deleted file mode 100644 index 7ce98d12fa..0000000000 --- a/client/modules/User/pages/EmailVerificationView.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useEffect, useMemo } from 'react'; -import { Helmet } from 'react-helmet'; -import { useLocation, useHistory } from 'react-router-dom'; -import { useSelector, useDispatch } from 'react-redux'; -import { useTranslation } from 'react-i18next'; -import { verifyEmailConfirmation } from '../actions'; -import { RootPage } from '../../../components/RootPage'; -import Nav from '../../IDE/components/Header/Nav'; -import type { RootState } from '../../../reducers'; - -export const EmailVerificationView = () => { - const { t } = useTranslation(); - const location = useLocation(); - const dispatch = useDispatch(); - const browserHistory = useHistory(); - const emailVerificationTokenState = useSelector( - (state: RootState) => state.user.emailVerificationTokenState - ); - const verificationToken = useMemo(() => { - const searchParams = new URLSearchParams(location.search); - return searchParams.get('t'); - }, [location.search]); - useEffect(() => { - if (verificationToken) { - dispatch(verifyEmailConfirmation(verificationToken)); - } - }, [dispatch, verificationToken]); - let status = null; - if (!verificationToken) { - status =

    {t('EmailVerificationView.InvalidTokenNull')}

    ; - } else if (emailVerificationTokenState === 'checking') { - status =

    {t('EmailVerificationView.Checking')}

    ; - } else if (emailVerificationTokenState === 'verified') { - status =

    {t('EmailVerificationView.Verified')}

    ; - setTimeout(() => browserHistory.push('/'), 1000); - } else if (emailVerificationTokenState === 'invalid') { - status =

    {t('EmailVerificationView.InvalidState')}

    ; - } - - return ( - -