diff --git a/server/src/middlewares/auth.ts b/server/src/middlewares/auth.ts index 75540ad5..eebef4a6 100644 --- a/server/src/middlewares/auth.ts +++ b/server/src/middlewares/auth.ts @@ -16,12 +16,12 @@ export const requireSignIn = (req: UserRequest, res: Response, next: any) => { } verify(token, secret, (err: any, user: any) => { - console.log(err) - - if (err) return res.sendStatus(403) - - req.user = user; - - next() - }) + if (err) { + console.log('JWT verification error:', err); + return res.sendStatus(403); + } + req.user = { ...user, id: user.userId }; // Normalize to include `id` + next(); + }); + }; diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 7a356b27..91272663 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -145,6 +145,80 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r }); +/** + * POST endpoint to duplicate a robot and update its target URL. + */ +router.post('/recordings/:id/duplicate', requireSignIn, async (req: AuthenticatedRequest, res) => { + try { + const { id } = req.params; + const { targetUrl } = req.body; + + if (!targetUrl) { + return res.status(400).json({ error: 'The "targetUrl" field is required.' }); + } + + const originalRobot = await Robot.findOne({ where: { 'recording_meta.id': id } }); + + if (!originalRobot) { + return res.status(404).json({ error: 'Original robot not found.' }); + } + + const lastWord = targetUrl.split('/').filter(Boolean).pop() || 'Unnamed'; + + const workflow = originalRobot.recording.workflow.map((step) => { + if (step.where?.url && step.where.url !== "about:blank") { + step.where.url = targetUrl; + } + + step.what.forEach((action) => { + if (action.action === "goto" && action.args?.length) { + action.args[0] = targetUrl; + } + }); + + return step; + }); + + const currentTimestamp = new Date().toISOString(); + + const newRobot = await Robot.create({ + id: uuid(), + userId: originalRobot.userId, + recording_meta: { + ...originalRobot.recording_meta, + id: uuid(), + name: `${originalRobot.recording_meta.name} (${lastWord})`, + createdAt: currentTimestamp, + updatedAt: currentTimestamp, + }, + recording: { ...originalRobot.recording, workflow }, + google_sheet_email: null, + google_sheet_name: null, + google_sheet_id: null, + google_access_token: null, + google_refresh_token: null, + schedule: null, + }); + + logger.log('info', `Robot with ID ${id} duplicated successfully as ${newRobot.id}.`); + + return res.status(201).json({ + message: 'Robot duplicated and target URL updated successfully.', + robot: newRobot, + }); + } catch (error) { + if (error instanceof Error) { + logger.log('error', `Error duplicating robot with ID ${req.params.id}: ${error.message}`); + return res.status(500).json({ error: error.message }); + } else { + logger.log('error', `Unknown error duplicating robot with ID ${req.params.id}`); + return res.status(500).json({ error: 'An unknown error occurred.' }); + } + } +}); + + + /** * DELETE endpoint for deleting a recording from the storage. diff --git a/src/api/storage.ts b/src/api/storage.ts index 0ad51e77..90ab91af 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -33,6 +33,22 @@ export const updateRecording = async (id: string, data: { name?: string; limit?: } }; +export const duplicateRecording = async (id: string, targetUrl: string): Promise => { + try { + const response = await axios.post(`${apiUrl}/storage/recordings/${id}/duplicate`, { + targetUrl, + }); + if (response.status === 201) { + return response.data; // Returns the duplicated robot details + } else { + throw new Error(`Couldn't duplicate recording with id ${id}`); + } + } catch (error: any) { + console.error(`Error duplicating recording: ${error.message}`); + return null; + } +}; + export const getStoredRuns = async (): Promise => { try { const response = await axios.get(`${apiUrl}/storage/runs`); diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index 84df8d0e..f309dcc8 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -91,9 +91,10 @@ interface RecordingsTableProps { handleIntegrateRecording: (id: string, fileName: string, params: string[]) => void; handleSettingsRecording: (id: string, fileName: string, params: string[]) => void; handleEditRobot: (id: string, name: string, params: string[]) => void; + handleDuplicateRobot: (id: string, name: string, params: string[]) => void; } -export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot }: RecordingsTableProps) => { +export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => { const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rows, setRows] = React.useState([]); @@ -264,8 +265,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl }) }} handleDuplicate={() => { - notify('info', 'Duplicating recording...'); - // Implement duplication logic here + handleDuplicateRobot(row.id, row.name, row.params || []); }} /> diff --git a/src/components/molecules/RobotDuplicate.tsx b/src/components/molecules/RobotDuplicate.tsx new file mode 100644 index 00000000..3fe6abb7 --- /dev/null +++ b/src/components/molecules/RobotDuplicate.tsx @@ -0,0 +1,164 @@ +import React, { useState, useEffect } from 'react'; +import { GenericModal } from "../atoms/GenericModal"; +import { TextField, Typography, Box, Button, Chip } from "@mui/material"; +import { modalStyle } from "./AddWhereCondModal"; +import { useGlobalInfoStore } from '../../context/globalInfo'; +import { duplicateRecording, getStoredRecording } from '../../api/storage'; +import { WhereWhatPair } from 'maxun-core'; +import { getUserById } from "../../api/auth"; + +interface RobotMeta { + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; +} + +interface RobotWorkflow { + workflow: WhereWhatPair[]; +} + +interface ScheduleConfig { + runEvery: number; + runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS'; + startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY'; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; + lastRunAt?: Date; + nextRunAt?: Date; + cronExpression?: string; +} + +export interface RobotSettings { + id: string; + userId?: number; + recording_meta: RobotMeta; + recording: RobotWorkflow; + google_sheet_email?: string | null; + google_sheet_name?: string | null; + google_sheet_id?: string | null; + google_access_token?: string | null; + google_refresh_token?: string | null; + schedule?: ScheduleConfig | null; +} + +interface RobotSettingsProps { + isOpen: boolean; + handleStart: (settings: RobotSettings) => void; + handleClose: () => void; + initialSettings?: RobotSettings | null; + +} + +export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { + const [robot, setRobot] = useState(null); + const [targetUrl, setTargetUrl] = useState(''); + const { recordingId, notify } = useGlobalInfoStore(); + + useEffect(() => { + if (isOpen) { + getRobot(); + } + }, [isOpen]); + + useEffect(() => { + // Update the targetUrl when the robot data is loaded + if (robot) { + const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1]; + const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0]; + setTargetUrl(url); + } + }, [robot]); + + const getRobot = async () => { + if (recordingId) { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } else { + notify('error', 'Could not find robot details. Please try again.'); + } + } + + // const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1]; + + // // Find the `goto` action in `what` and retrieve its arguments + // const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0]; + + const handleTargetUrlChange = (e: React.ChangeEvent) => { + setTargetUrl(e.target.value); + }; + + const handleSave = async () => { + if (!robot || !targetUrl) { + notify('error', 'Target URL is required.'); + return; + } + + console.log("handle save"); + + try { + const success = await duplicateRecording(robot.recording_meta.id, targetUrl); + + if (success) { + notify('success', 'Target URL updated successfully.'); + handleStart(robot); // Inform parent about the updated robot + handleClose(); // Close the modal + + window.location.reload(); + } else { + notify('error', 'Failed to update the Target URL. Please try again.'); + } + } catch (error) { + notify('error', 'An error occurred while updating the Target URL.'); + console.error('Error updating Target URL:', error); + } + }; + + return ( + + <> + Duplicate Robot + + { + robot && ( + <> + Robot duplication is useful to extract data from pages with the same structure. +
+ + Example: If you've created a robot for producthunt.com/topics/api, you can duplicate it to scrape similar pages + like producthunt.com/topics/database without training a robot from scratch. + +
+ + ⚠️ Ensure the new page has the same structure as the original page. + + + + + + + + ) + } +
+ +
+ ); +}; diff --git a/src/components/molecules/RobotEdit.tsx b/src/components/molecules/RobotEdit.tsx index f26765a3..26317260 100644 --- a/src/components/molecules/RobotEdit.tsx +++ b/src/components/molecules/RobotEdit.tsx @@ -54,10 +54,11 @@ interface RobotSettingsProps { handleStart: (settings: RobotSettings) => void; handleClose: () => void; initialSettings?: RobotSettings | null; - + } export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { + console.log("robot edit"); const [robot, setRobot] = useState(null); const { recordingId, notify } = useGlobalInfoStore(); diff --git a/src/components/organisms/Recordings.tsx b/src/components/organisms/Recordings.tsx index ecb2b769..495a25f8 100644 --- a/src/components/organisms/Recordings.tsx +++ b/src/components/organisms/Recordings.tsx @@ -6,6 +6,7 @@ import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSe import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings"; import { RobotSettings, RobotSettingsModal } from "../molecules/RobotSettings"; import { RobotEditModal } from '../molecules/RobotEdit'; +import { RobotDuplicationModal } from '../molecules/RobotDuplicate'; interface RecordingsProps { handleEditRecording: (id: string, fileName: string) => void; @@ -20,11 +21,13 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false); const [robotSettingsAreOpen, setRobotSettingsAreOpen] = useState(false); const [robotEditAreOpen, setRobotEditAreOpen] = useState(false); + const [robotDuplicateAreOpen, setRobotDuplicateAreOpen] = useState(false); const [params, setParams] = useState([]); const [selectedRecordingId, setSelectedRecordingId] = useState(''); const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => {}; const handleSettingsRecording = (id: string, settings: RobotSettings) => {}; const handleEditRobot = (id: string, settings: RobotSettings) => {}; + const handleDuplicateRobot = (id: string, settings: RobotSettings) => {}; const handleSettingsAndIntegrate = (id: string, name: string, params: string[]) => { if (params.length === 0) { @@ -91,6 +94,19 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi } } + const handleDuplicateRobotOption = (id: string, name: string, params: string[]) => { + if (params.length === 0) { + setRobotDuplicateAreOpen(true); + setRecordingInfo(id, name); + setSelectedRecordingId(id); + } else { + setParams(params); + setRobotDuplicateAreOpen(true); + setRecordingInfo(id, name); + setSelectedRecordingId(id); + } + } + const handleClose = () => { setParams([]); setRunSettingsAreOpen(false); @@ -126,6 +142,13 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi setSelectedRecordingId(''); } + const handleRobotDuplicateClose = () => { + setParams([]); + setRobotDuplicateAreOpen(false); + setRecordingInfo('', ''); + setSelectedRecordingId(''); + } + return ( handleEditRobot(selectedRecordingId,settings)} /> + handleDuplicateRobot(selectedRecordingId, settings)} + /> diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 94cd9fda..5c46e4de 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -1,7 +1,6 @@ import { useReducer, createContext, useEffect } from 'react'; import axios from 'axios'; import { useNavigate } from 'react-router-dom'; -import { jwtDecode } from 'jwt-decode'; import { apiUrl } from "../apiConfig"; interface AuthProviderProps { @@ -51,74 +50,28 @@ const AuthProvider = ({ children }: AuthProviderProps) => { const navigate = useNavigate(); axios.defaults.withCredentials = true; - const logoutUser = () => { - dispatch({ type: 'LOGOUT' }); - window.localStorage.removeItem('user'); - window.localStorage.removeItem('logoutTimeout'); - navigate('/login'); - }; - - const checkTokenExpiration = (token: string) => { - if (!token) return; - - try { - const decodedToken: any = jwtDecode(token); - const currentTime = Date.now(); - const tokenExpiryTime = decodedToken.exp * 1000; - - if (tokenExpiryTime > currentTime) { - // Calculate remaining time until token expires - const remainingTime = tokenExpiryTime - currentTime; - - // Check if a logout timeout already exists in local storage - const existingTimeout = window.localStorage.getItem('logoutTimeout'); - - if (!existingTimeout) { - // Set a timeout for auto-logout - const timeoutId = setTimeout(logoutUser, remainingTime); - window.localStorage.setItem('logoutTimeout', JSON.stringify(timeoutId)); - } - } else { - logoutUser(); // Immediately logout if token is expired - } - } catch (error) { - console.error("Error decoding token:", error); - logoutUser(); - } - }; - useEffect(() => { const storedUser = window.localStorage.getItem('user'); if (storedUser) { - const userData = JSON.parse(storedUser); - dispatch({ type: 'LOGIN', payload: userData }); - - // Run expiration check only if a token exists - if (userData.token) { - checkTokenExpiration(userData.token); - } + dispatch({ type: 'LOGIN', payload: JSON.parse(storedUser) }); } - // Clean up timeout on component unmount - return () => { - const timeoutId = window.localStorage.getItem('logoutTimeout'); - if (timeoutId) { - clearTimeout(JSON.parse(timeoutId)); - window.localStorage.removeItem('logoutTimeout'); - } - }; - }, []); // Only run this effect once on mount + }, []); axios.interceptors.response.use( - (response) => response, - (error) => { + function (response) { + return response; + }, + function (error) { const res = error.response; - if (res?.status === 401 && !res.config.__isRetryRequest) { + if (res.status === 401 && res.config && !res.config.__isRetryRequest) { return new Promise((resolve, reject) => { axios .get(`${apiUrl}/auth/logout`) .then(() => { console.log('/401 error > logout'); - logoutUser(); + dispatch({ type: 'LOGOUT' }); + window.localStorage.removeItem('user'); + navigate('/login'); }) .catch((err) => { console.error('AXIOS INTERCEPTORS ERROR:', err); @@ -129,7 +82,7 @@ const AuthProvider = ({ children }: AuthProviderProps) => { return Promise.reject(error); } ); - + return ( {children} diff --git a/src/index.css b/src/index.css index 457f850b..721e4d67 100644 --- a/src/index.css +++ b/src/index.css @@ -28,7 +28,8 @@ a { code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; + monospace; + color: #ff00c3; } #browser-actions { @@ -53,9 +54,6 @@ code { transform-origin: top left; /* Keep the position fixed */ } -#browser { -} - #browser-window { overflow-y: auto; height: 100%;