diff --git a/public/locales/de.json b/public/locales/de.json index 14664774..21b71313 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -32,6 +32,7 @@ "new": "Roboter erstellen", "modal": { "title": "Geben Sie die URL ein", + "login_title": "Erfordert diese Seite eine Anmeldung?", "label": "URL", "button": "Aufnahme starten" }, diff --git a/public/locales/en.json b/public/locales/en.json index 88622f20..669c9b57 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -32,6 +32,7 @@ "new":"Create Robot", "modal":{ "title":"Enter the URL", + "login_title": "Does this site require login?", "label":"URL", "button":"Start Recording" }, diff --git a/public/locales/es.json b/public/locales/es.json index 7f669096..8ef9b8bf 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -32,6 +32,7 @@ "new": "Crear Robot", "modal": { "title": "Ingresa la URL", + "login_title": "¿Este sitio requiere inicio de sesión?", "label": "URL", "button": "Comenzar grabación" }, diff --git a/public/locales/ja.json b/public/locales/ja.json index 5680471d..03f91cf6 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -32,6 +32,7 @@ "new": "ロボットを作成", "modal": { "title": "URLを入力してください", + "login_title": "このサイトはログインが必要ですか?", "label": "URL", "button": "録画を開始" }, diff --git a/public/locales/zh.json b/public/locales/zh.json index 536ea70c..41e3a762 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -32,6 +32,7 @@ "new": "创建机器人", "modal": { "title": "输入URL", + "login_title": "此网站需要登录吗?", "label": "URL", "button": "开始录制" }, diff --git a/server/src/models/Robot.ts b/server/src/models/Robot.ts index 3b2717d6..0cd11b51 100644 --- a/server/src/models/Robot.ts +++ b/server/src/models/Robot.ts @@ -26,6 +26,7 @@ interface RobotAttributes { google_access_token?: string | null; google_refresh_token?: string | null; schedule?: ScheduleConfig | null; + isLogin?: boolean; } interface ScheduleConfig { @@ -54,6 +55,7 @@ class Robot extends Model implements R public google_access_token!: string | null; public google_refresh_token!: string | null; public schedule!: ScheduleConfig | null; + public isLogin!: boolean; } Robot.init( @@ -99,6 +101,11 @@ Robot.init( type: DataTypes.JSONB, allowNull: true, }, + isLogin: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, }, { sequelize, diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index ddadf240..007ac40c 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -18,12 +18,43 @@ import { AuthenticatedRequest } from './record'; import { computeNextRun } from '../utils/schedule'; import { capture } from "../utils/analytics"; import { tryCatch } from 'bullmq'; +import { encrypt, decrypt } from '../utils/auth'; import { WorkflowFile } from 'maxun-core'; import { Page } from 'playwright'; chromium.use(stealthPlugin()); export const router = Router(); +export const decryptWorkflowActions = async (workflow: any[],): Promise => { + // Create a deep copy to avoid mutating the original workflow + const processedWorkflow = JSON.parse(JSON.stringify(workflow)); + + // Process each step in the workflow + for (const step of processedWorkflow) { + if (!step.what) continue; + + // Process each action in the step + for (const action of step.what) { + // Only process type and press actions + if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) { + // The second argument contains the encrypted value + const encryptedValue = action.args[1]; + if (typeof encryptedValue === 'string') { + try { + // Decrypt the value and update the args array + action.args[1] = await decrypt(encryptedValue); + } catch (error) { + console.error('Failed to decrypt value:', error); + // Keep the encrypted value if decryption fails + } + } + } + } + } + + return processedWorkflow; +}; + /** * Logs information about recordings API. */ @@ -55,6 +86,13 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => { raw: true } ); + + if (data?.recording?.workflow) { + data.recording.workflow = await decryptWorkflowActions( + data.recording.workflow, + ); + } + return res.send(data); } catch (e) { logger.log('info', 'Error while reading robots'); @@ -116,13 +154,70 @@ function formatRunResponse(run: any) { return formattedRun; } +interface CredentialUpdate { + [selector: string]: string; +} + +function updateTypeActionsInWorkflow(workflow: any[], credentials: CredentialUpdate) { + return workflow.map(step => { + if (!step.what) return step; + + // First pass: mark indices to remove + const indicesToRemove = new Set(); + step.what.forEach((action: any, index: any) => { + if (!action.action || !action.args?.[0]) return; + + // If it's a type/press action for a credential + if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) { + indicesToRemove.add(index); + // Check if next action is waitForLoadState + if (step.what[index + 1]?.action === 'waitForLoadState') { + indicesToRemove.add(index + 1); + } + } + }); + + // Filter out marked indices and create new what array + const filteredWhat = step.what.filter((_: any, index: any) => !indicesToRemove.has(index)); + + // Add new type actions after click actions + Object.entries(credentials).forEach(([selector, credential]) => { + const clickIndex = filteredWhat.findIndex((action: any) => + action.action === 'click' && action.args?.[0] === selector + ); + + if (clickIndex !== -1) { + const chars = credential.split(''); + chars.forEach((char, i) => { + // Add type action + filteredWhat.splice(clickIndex + 1 + (i * 2), 0, { + action: 'type', + args: [selector, encrypt(char)] + }); + + // Add waitForLoadState + filteredWhat.splice(clickIndex + 2 + (i * 2), 0, { + action: 'waitForLoadState', + args: ['networkidle'] + }); + }); + } + }); + + return { + ...step, + what: filteredWhat + }; + }); +} + /** * PUT endpoint to update the name and limit of a robot. */ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, res) => { try { const { id } = req.params; - const { name, limit } = req.body; + const { name, limit, credentials } = req.body; // Validate input if (!name && limit === undefined) { @@ -141,17 +236,21 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r robot.set('recording_meta', { ...robot.recording_meta, name }); } + let workflow = [...robot.recording.workflow]; // Create a copy of the workflow + + if (credentials) { + workflow = updateTypeActionsInWorkflow(workflow, credentials); + } + // Update the limit if (limit !== undefined) { - const workflow = [...robot.recording.workflow]; // Create a copy of the workflow - // Ensure the workflow structure is valid before updating if ( workflow.length > 0 && workflow[0]?.what?.[0] ) { // Create a new workflow object with the updated limit - const updatedWorkflow = workflow.map((step, index) => { + workflow = workflow.map((step, index) => { if (index === 0) { // Assuming you want to update the first step return { ...step, @@ -173,14 +272,13 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r } return step; }); - - // Replace the workflow in the recording object - robot.set('recording', { ...robot.recording, workflow: updatedWorkflow }); } else { return res.status(400).json({ error: 'Invalid workflow structure for updating limit.' }); } } + robot.set('recording', { ...robot.recording, workflow }); + await robot.save(); const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } }); @@ -248,6 +346,7 @@ router.post('/recordings/:id/duplicate', requireSignIn, async (req: Authenticate updatedAt: currentTimestamp, }, recording: { ...originalRobot.recording, workflow }, + isLogin: originalRobot.isLogin, google_sheet_email: null, google_sheet_name: null, google_sheet_id: null, diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index 7368f0cb..47e6f377 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -134,9 +134,9 @@ export class WorkflowGenerator { */ private registerEventHandlers = (socket: Socket) => { socket.on('save', (data) => { - const { fileName, userId } = data; + const { fileName, userId, isLogin } = data; logger.log('debug', `Saving workflow ${fileName} for user ID ${userId}`); - this.saveNewWorkflow(fileName, userId); + this.saveNewWorkflow(fileName, userId, isLogin); }); socket.on('new-recording', () => this.workflowRecord = { workflow: [], @@ -660,7 +660,7 @@ export class WorkflowGenerator { * @param fileName The name of the file. * @returns {Promise} */ - public saveNewWorkflow = async (fileName: string, userId: number) => { + public saveNewWorkflow = async (fileName: string, userId: number, isLogin: boolean) => { const recording = this.optimizeWorkflow(this.workflowRecord); try { this.recordingMeta = { @@ -675,6 +675,7 @@ export class WorkflowGenerator { userId, recording_meta: this.recordingMeta, recording: recording, + isLogin: isLogin, }); capture( 'maxun-oss-robot-created', diff --git a/src/api/storage.ts b/src/api/storage.ts index 201a38cc..c7901f22 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -5,6 +5,10 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings"; import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; import { apiUrl } from "../apiConfig"; +interface Credentials { + [key: string]: string; +} + export const getStoredRecordings = async (): Promise => { try { const response = await axios.get(`${apiUrl}/storage/recordings`); @@ -19,7 +23,7 @@ export const getStoredRecordings = async (): Promise => { } }; -export const updateRecording = async (id: string, data: { name?: string; limit?: number }): Promise => { +export const updateRecording = async (id: string, data: { name?: string; limit?: number, credentials?: Credentials }): Promise => { try { const response = await axios.put(`${apiUrl}/storage/recordings/${id}`, data); if (response.status === 200) { diff --git a/src/components/recorder/SaveRecording.tsx b/src/components/recorder/SaveRecording.tsx index 4a2d0ba4..c6b5687b 100644 --- a/src/components/recorder/SaveRecording.tsx +++ b/src/components/recorder/SaveRecording.tsx @@ -22,7 +22,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { const [recordingName, setRecordingName] = useState(fileName); const [waitingForSave, setWaitingForSave] = useState(false); - const { browserId, setBrowserId, notify, recordings } = useGlobalInfoStore(); + const { browserId, setBrowserId, notify, recordings, isLogin } = useGlobalInfoStore(); const { socket } = useSocketStore(); const { state, dispatch } = useContext(AuthContext); const { user } = state; @@ -59,7 +59,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { // releases resources and changes the view for main page by clearing the global browserId const saveRecording = async () => { if (user) { - const payload = { fileName: recordingName, userId: user.id }; + const payload = { fileName: recordingName, userId: user.id, isLogin: isLogin }; socket?.emit('save', payload); setWaitingForSave(true); console.log(`Saving the recording as ${recordingName} for userId ${user.id}`); diff --git a/src/components/robot/RecordingsTable.tsx b/src/components/robot/RecordingsTable.tsx index 87327b7e..e7409b89 100644 --- a/src/components/robot/RecordingsTable.tsx +++ b/src/components/robot/RecordingsTable.tsx @@ -11,7 +11,7 @@ import TableRow from '@mui/material/TableRow'; import { useEffect } from "react"; import { WorkflowFile } from "maxun-core"; import SearchIcon from '@mui/icons-material/Search'; -import { IconButton, Button, Box, Typography, TextField, MenuItem, Menu, ListItemIcon, ListItemText, CircularProgress } from "@mui/material"; +import { IconButton, Button, Box, Typography, TextField, MenuItem, Menu, ListItemIcon, ListItemText, CircularProgress, RadioGroup, FormControlLabel, Radio } from "@mui/material"; import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power, ContentCopy, MoreHoriz } from "@mui/icons-material"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; @@ -85,7 +85,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl }, ]; - const { notify, setRecordings, browserId, setBrowserId, setInitialUrl, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore(); + const { notify, setRecordings, browserId, setBrowserId, setInitialUrl, recordingUrl, setRecordingUrl, isLogin, setIsLogin, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore(); const navigate = useNavigate(); const handleChangePage = (event: unknown, newPage: number) => { @@ -321,6 +321,19 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl onChange={setBrowserRecordingUrl} style={{ marginBottom: '20px', marginTop: '20px' }} /> + + {t('recordingtable.modal.login_title')} + setIsLogin(e.target.value === 'yes')} + style={{ marginBottom: '20px' }} + > + } label="Yes" /> + } label="No" /> + +