Merge branch 'develop' into search_logic

This commit is contained in:
Amit Chauhan
2024-11-20 14:31:48 +05:30
committed by GitHub
35 changed files with 2024 additions and 990 deletions

View File

@@ -1,12 +1,12 @@
# App Setup # App Setup
NODE_ENV=production # Set to 'development' or 'production' as required NODE_ENV=production # Set to 'development' or 'production' as required
JWT_SECRET=your_jwt_secret_key # Replace with a secure JWT secret key JWT_SECRET=a9Z$kLq7^f03GzNw!bP9dH4xV6sT2yXl3O8vR@uYq3 # Replace with a secure JWT secret key
DB_NAME=maxun # Your PostgreSQL database name DB_NAME=maxun # Your PostgreSQL database name
DB_USER=postgres # PostgreSQL username DB_USER=postgres # PostgreSQL username
DB_PASSWORD=postgres # PostgreSQL password DB_PASSWORD=postgres # PostgreSQL password
DB_HOST=postgres # Host for PostgreSQL in Docker DB_HOST=postgres # Host for PostgreSQL in Docker
DB_PORT=5432 # Port for PostgreSQL (default: 5432) DB_PORT=5432 # Port for PostgreSQL (default: 5432)
ENCRYPTION_KEY=your_encryption_key # Key for encrypting sensitive data (passwords and proxies) ENCRYPTION_KEY=f4d5e6a7b8c9d0e1f23456789abcdef01234567890abcdef123456789abcdef0 # Key for encrypting sensitive data (passwords and proxies)
MINIO_ENDPOINT=minio # MinIO endpoint in Docker MINIO_ENDPOINT=minio # MinIO endpoint in Docker
MINIO_PORT=9000 # Port for MinIO (default: 9000) MINIO_PORT=9000 # Port for MinIO (default: 9000)
MINIO_ACCESS_KEY=minio_access_key # MinIO access key MINIO_ACCESS_KEY=minio_access_key # MinIO access key

View File

@@ -20,6 +20,9 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
<a href="https://x.com/maxun_io"><b>Twitter</b></a> | <a href="https://x.com/maxun_io"><b>Twitter</b></a> |
<a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> | <a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> |
<a href="https://www.youtube.com/@MaxunOSS"><b>Watch Tutorials</b></a> <a href="https://www.youtube.com/@MaxunOSS"><b>Watch Tutorials</b></a>
<br />
<br />
<a href="https://trendshift.io/repositories/12113" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12113" alt="getmaxun%2Fmaxun | Trendshift" style="width: 250px; height: 55px; margin-top: 10px;" width="250" height="55"/></a>
</p> </p>
![maxun_demo](https://github.com/user-attachments/assets/a61ba670-e56a-4ae1-9681-0b4bd6ba9cdc) ![maxun_demo](https://github.com/user-attachments/assets/a61ba670-e56a-4ae1-9681-0b4bd6ba9cdc)
@@ -49,6 +52,16 @@ npm install
cd maxun-core cd maxun-core
npm install npm install
# get back to the root directory
cd ..
# make sure playwright is properly initialized
npx playwright install
npx playwright install-deps
# get back to the root directory
cd ..
# start frontend and backend together # start frontend and backend together
npm run start npm run start
``` ```
@@ -61,8 +74,8 @@ You can access the frontend at http://localhost:5173/ and backend at http://loca
| Variable | Mandatory | Description | If Not Set | | Variable | Mandatory | Description | If Not Set |
|-----------------------|-----------|----------------------------------------------------------------------------------------------|--------------------------------------------------------------| |-----------------------|-----------|----------------------------------------------------------------------------------------------|--------------------------------------------------------------|
| `BACKEND_URL` | Yes | URL to run backend on. | Backend won't start. If not sure, set to http://localhost:8080 | | `BACKEND_URL` | Yes | URL to run backend on. | Default value: http://localhost:8080 |
| `VITE_BACKEND_URL` | Yes | URL to run backend on. | Backend won't start. If not sure, set to http://localhost:8080 | | `VITE_BACKEND_URL` | Yes | URL used by frontend to connect to backend | Default value: http://localhost:8080 |
| `JWT_SECRET` | Yes | Secret key used to sign and verify JSON Web Tokens (JWTs) for authentication. | JWT authentication will not work. | | `JWT_SECRET` | Yes | Secret key used to sign and verify JSON Web Tokens (JWTs) for authentication. | JWT authentication will not work. |
| `DB_NAME` | Yes | Name of the Postgres database to connect to. | Database connection will fail. | | `DB_NAME` | Yes | Name of the Postgres database to connect to. | Database connection will fail. |
| `DB_USER` | Yes | Username for Postgres database authentication. | Database connection will fail. | | `DB_USER` | Yes | Username for Postgres database authentication. | Database connection will fail. |

View File

@@ -32,9 +32,10 @@ services:
environment: environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY} MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY} MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
command: server /data command: server /data --console-address :9001
ports: ports:
- "9000:9000" - "9000:9000" # API port
- "9001:9001" # WebUI port
volumes: volumes:
- minio_data:/data - minio_data:/data

0
esbuild.config.js Normal file
View File

View File

@@ -39,6 +39,7 @@
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"joi": "^17.6.0", "joi": "^17.6.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
"loglevel": "^1.8.0", "loglevel": "^1.8.0",
"loglevel-plugin-remote": "^0.6.8", "loglevel-plugin-remote": "^0.6.8",
"maxun-core": "^0.0.3", "maxun-core": "^0.0.3",
@@ -46,7 +47,7 @@
"moment-timezone": "^0.5.45", "moment-timezone": "^0.5.45",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"pg": "^8.13.0", "pg": "^8.13.0",
"playwright": "^1.20.1", "playwright": "^1.48.2",
"playwright-extra": "^4.3.6", "playwright-extra": "^4.3.6",
"posthog-node": "^4.2.1", "posthog-node": "^4.2.1",
"prismjs": "^1.28.0", "prismjs": "^1.28.0",
@@ -89,6 +90,7 @@
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "^1.4.7", "@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/js-cookie": "^3.0.6",
"@types/loglevel": "^1.6.3", "@types/loglevel": "^1.6.3",
"@types/node": "22.7.9", "@types/node": "22.7.9",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
@@ -102,6 +104,7 @@
"ajv": "^8.8.2", "ajv": "^8.8.2",
"concurrently": "^7.0.0", "concurrently": "^7.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"js-cookie": "^3.0.5",
"nodemon": "^2.0.15", "nodemon": "^2.0.15",
"ts-node": "^10.4.0", "ts-node": "^10.4.0",
"vite": "^5.4.10" "vite": "^5.4.10"

View File

@@ -289,7 +289,7 @@ router.get("/robots/:id", requireAPIKey, async (req: Request, res: Response) =>
* type: string * type: string
* example: "Failed to retrieve runs" * example: "Failed to retrieve runs"
*/ */
router.get("/robots/:id/runs", requireAPIKey, async (req: Request, res: Response) => { router.get("/robots/:id/runs",requireAPIKey, async (req: Request, res: Response) => {
try { try {
const runs = await Run.findAll({ const runs = await Run.findAll({
where: { where: {
@@ -321,6 +321,7 @@ router.get("/robots/:id/runs", requireAPIKey, async (req: Request, res: Response
} }
); );
function formatRunResponse(run: any) { function formatRunResponse(run: any) {
const formattedRun = { const formattedRun = {
id: run.id, id: run.id,
@@ -494,7 +495,7 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) {
} catch (e) { } catch (e) {
const { message } = e as Error; const { message } = e as Error;
logger.log('info', `Error while scheduling a run with id: ${id}`); logger.log('info', `Error while scheduling a run with id: ${id}`);
console.log(message); console.log(`Error scheduling run:`, message);
return { return {
success: false, success: false,
error: message, error: message,
@@ -766,7 +767,6 @@ router.post("/robots/:id/runs", requireAPIKey, async (req: AuthenticatedRequest,
return res.status(401).json({ ok: false, error: 'Unauthorized' }); return res.status(401).json({ ok: false, error: 'Unauthorized' });
} }
const runId = await handleRunRecording(req.params.id, req.user.dataValues.id); const runId = await handleRunRecording(req.params.id, req.user.dataValues.id);
console.log(`Result`, runId);
if (!runId) { if (!runId) {
throw new Error('Run ID is undefined'); throw new Error('Run ID is undefined');

View File

@@ -163,9 +163,7 @@ export class RemoteBrowser {
contextOptions.userAgent = browserUserAgent; contextOptions.userAgent = browserUserAgent;
this.context = await this.browser.newContext(contextOptions); this.context = await this.browser.newContext(contextOptions);
console.log(`Context from initialize: ${JSON.stringify(this.context)}`)
this.currentPage = await this.context.newPage(); this.currentPage = await this.context.newPage();
console.log(`CPage from initialize: ${JSON.stringify(this.currentPage)}`)
// await this.currentPage.setExtraHTTPHeaders({ // await this.currentPage.setExtraHTTPHeaders({
// 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3' // 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
// }); // });
@@ -307,6 +305,7 @@ export class RemoteBrowser {
const workflow = this.generator.AddGeneratedFlags(this.generator.getWorkflowFile()); const workflow = this.generator.AddGeneratedFlags(this.generator.getWorkflowFile());
await this.initializeNewPage(); await this.initializeNewPage();
if (this.currentPage) { if (this.currentPage) {
this.currentPage.setViewportSize({ height: 400, width: 900 });
const params = this.generator.getParams(); const params = this.generator.getParams();
if (params) { if (params) {
this.interpreterSettings.params = params.reduce((acc, param) => { this.interpreterSettings.params = params.reduce((acc, param) => {

View File

@@ -271,7 +271,6 @@ const handleChangeUrl = async (generator: WorkflowGenerator, page: Page, url: st
try { try {
await page.goto(url); await page.goto(url);
logger.log('debug', `Went to ${url}`); logger.log('debug', `Went to ${url}`);
console.log(`Went to ${url}`)
} catch (e) { } catch (e) {
const { message } = e as Error; const { message } = e as Error;
logger.log('error', message); logger.log('error', message);

View File

@@ -16,12 +16,16 @@ export const requireSignIn = (req: UserRequest, res: Response, next: any) => {
} }
verify(token, secret, (err: any, user: any) => { verify(token, secret, (err: any, user: any) => {
console.log(err) if (err) {
console.log('JWT verification error:', err);
if (err) return res.sendStatus(403) return res.sendStatus(403);
}
// Normalize payload key
if (user.userId && !user.id) {
user.id = user.userId;
delete user.userId; // temporary: del the old key for clarity
}
req.user = user; req.user = user;
next();
next() });
})
}; };

View File

@@ -1,480 +1,557 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from "express";
import User from '../models/User'; import User from "../models/User";
import Robot from '../models/Robot'; import Robot from "../models/Robot";
import jwt from 'jsonwebtoken'; import jwt from "jsonwebtoken";
import { hashPassword, comparePassword } from '../utils/auth'; import { hashPassword, comparePassword } from "../utils/auth";
import { requireSignIn } from '../middlewares/auth'; import { requireSignIn } from "../middlewares/auth";
import { genAPIKey } from '../utils/api'; import { genAPIKey } from "../utils/api";
import { google } from 'googleapis'; import { google } from "googleapis";
import { capture } from "../utils/analytics" import { capture } from "../utils/analytics";
export const router = Router(); export const router = Router();
interface AuthenticatedRequest extends Request { interface AuthenticatedRequest extends Request {
user?: { id: string }; user?: { id: string };
} }
router.post('/register', async (req, res) => { router.post("/register", async (req, res) => {
console.log('Received request at /auth/register'); try {
console.log('Received body:', req.body); const { email, password } = req.body;
if (!email) return res.status(400).send("Email is required");
if (!password || password.length < 6)
return res
.status(400)
.send("Password is required and must be at least 6 characters");
let userExist = await User.findOne({ raw: true, where: { email } });
if (userExist) return res.status(400).send("User already exists");
const hashedPassword = await hashPassword(password);
let user: any;
try { try {
const { email, password } = req.body user = await User.create({ email, password: hashedPassword });
if (!email) return res.status(400).send('Email is required')
if (!password || password.length < 6) return res.status(400).send('Password is required and must be at least 6 characters')
let userExist = await User.findOne({ raw: true, where: { email } });
if (userExist) return res.status(400).send('User already exists')
const hashedPassword = await hashPassword(password)
let user: any;
try {
user = await User.create({ email, password: hashedPassword });
} catch (
error: any
) {
console.log(`Could not create user - ${error}`)
return res.status(500).send(`Could not create user - ${error.message}`)
}
if (!process.env.JWT_SECRET) {
console.log('JWT_SECRET is not defined in the environment');
return res.status(500).send('Internal Server Error');
}
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { expiresIn: '12h' });
user.password = undefined as unknown as string
res.cookie('token', token, {
httpOnly: true
})
capture(
'maxun-oss-user-registered',
{
email: user.email,
userId: user.id,
registeredAt: new Date().toISOString()
}
)
console.log(`User registered - ${user.email}`)
res.json(user)
} catch (error: any) { } catch (error: any) {
console.log(`Could not register user - ${error}`) console.log(`Could not create user - ${error}`);
res.status(500).send(`Could not register user - ${error.message}`) return res.status(500).send(`Could not create user - ${error.message}`);
} }
})
router.post('/login', async (req, res) => { if (!process.env.JWT_SECRET) {
try { console.log("JWT_SECRET is not defined in the environment");
const { email, password } = req.body; return res.status(500).send("Internal Server Error");
if (!email || !password) return res.status(400).send('Email and password are required')
if (password.length < 6) return res.status(400).send('Password must be at least 6 characters')
let user = await User.findOne({ raw: true, where: { email } });
if (!user) return res.status(400).send('User does not exist');
const match = await comparePassword(password, user.password)
if (!match) return res.status(400).send('Invalid email or password')
const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string, { expiresIn: '12h' });
// return user and token to client, exclude hashed password
if (user) {
user.password = undefined as unknown as string;
}
res.cookie('token', token, {
httpOnly: true
})
res.json(user)
} catch (error: any) {
res.status(400).send(`Could not login user - ${error.message}`)
console.log(`Could not login user - ${error}`)
} }
})
router.get('/logout', async (req, res) => { const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string);
try { user.password = undefined as unknown as string;
res.clearCookie('token') res.cookie("token", token, {
return res.json({ message: 'Logout successful' }) httpOnly: true,
} catch (error: any) { });
res.status(500).send(`Could not logout user - ${error.message}`) capture("maxun-oss-user-registered", {
} email: user.email,
}) userId: user.id,
registeredAt: new Date().toISOString(),
router.get('/current-user', requireSignIn, async (req: AuthenticatedRequest, res) => { });
try { console.log(`User registered - ${user.email}`);
if (!req.user) { res.json(user);
return res.status(401).json({ ok: false, error: 'Unauthorized' }); } catch (error: any) {
} console.log(`Could not register user - ${error}`);
const user = await User.findByPk(req.user.id, { res.status(500).send(`Could not register user - ${error.message}`);
attributes: { exclude: ['password'] }, }
});
if (!user) {
return res.status(404).json({ ok: false, error: 'User not found' });
} else {
return res.status(200).json({ ok: true, user: user });
}
} catch (error: any) {
console.error('Error in current-user route:', error);
return res.status(500).json({ ok: false, error: `Could not fetch current user: ${error.message}` });
}
}); });
router.get('/user/:id', requireSignIn, async (req: AuthenticatedRequest, res) => { router.post("/login", async (req, res) => {
try { try {
const { id } = req.params; const { email, password } = req.body;
if (!id) { if (!email || !password)
return res.status(400).json({ message: 'User ID is required' }); return res.status(400).send("Email and password are required");
} if (password.length < 6)
return res.status(400).send("Password must be at least 6 characters");
const user = await User.findByPk(id, { let user = await User.findOne({ raw: true, where: { email } });
attributes: { exclude: ['password'] }, if (!user) return res.status(400).send("User does not exist");
});
if (!user) { const match = await comparePassword(password, user.password);
return res.status(404).json({ message: 'User not found' }); if (!match) return res.status(400).send("Invalid email or password");
}
return res.status(200).json({ message: 'User fetched successfully', user }); const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string);
} catch (error: any) {
return res.status(500).json({ message: 'Error fetching user', error: error.message }); // return user and token to client, exclude hashed password
if (user) {
user.password = undefined as unknown as string;
} }
res.cookie("token", token, {
httpOnly: true,
});
capture("maxun-oss-user-login", {
email: user.email,
userId: user.id,
loggedInAt: new Date().toISOString(),
});
res.json(user);
} catch (error: any) {
res.status(400).send(`Could not login user - ${error.message}`);
console.log(`Could not login user - ${error}`);
}
}); });
router.post('/generate-api-key', requireSignIn, async (req: AuthenticatedRequest, res) => { router.get("/logout", async (req, res) => {
try {
res.clearCookie("token");
return res.json({ message: "Logout successful" });
} catch (error: any) {
res.status(500).send(`Could not logout user - ${error.message}`);
}
});
router.get(
"/current-user",
requireSignIn,
async (req: AuthenticatedRequest, res) => {
try { try {
if (!req.user) { if (!req.user) {
return res.status(401).json({ ok: false, error: 'Unauthorized' }); return res.status(401).json({ ok: false, error: "Unauthorized" });
} }
const user = await User.findByPk(req.user.id, { const user = await User.findByPk(req.user.id, {
attributes: { exclude: ['password'] }, attributes: { exclude: ["password"] },
});
if (!user) {
return res.status(404).json({ ok: false, error: "User not found" });
} else {
return res.status(200).json({ ok: true, user: user });
}
} catch (error: any) {
console.error("Error in current-user route:", error);
return res
.status(500)
.json({
ok: false,
error: `Could not fetch current user: ${error.message}`,
}); });
}
}
);
if (!user) { router.get(
return res.status(404).json({ message: 'User not found' }); "/user/:id",
} requireSignIn,
async (req: AuthenticatedRequest, res) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ message: "User ID is required" });
}
if (user.api_key) { const user = await User.findByPk(id, {
return res.status(400).json({ message: 'API key already exists' }); attributes: { exclude: ["password"] },
} });
const apiKey = genAPIKey();
await user.update({ api_key: apiKey }); if (!user) {
return res.status(404).json({ message: "User not found" });
}
capture( return res
'maxun-oss-api-key-created', .status(200)
{ .json({ message: "User fetched successfully", user });
user_id: user.id, } catch (error: any) {
created_at: new Date().toISOString() return res
} .status(500)
) .json({ message: "Error fetching user", error: error.message });
}
}
);
return res.status(200).json({ router.post(
message: 'API key generated successfully', "/generate-api-key",
api_key: apiKey, requireSignIn,
}); async (req: AuthenticatedRequest, res) => {
try {
if (!req.user) {
return res.status(401).json({ ok: false, error: "Unauthorized" });
}
const user = await User.findByPk(req.user.id, {
attributes: { exclude: ["password"] },
});
if (!user) {
return res.status(404).json({ message: "User not found" });
}
if (user.api_key) {
return res.status(400).json({ message: "API key already exists" });
}
const apiKey = genAPIKey();
await user.update({ api_key: apiKey });
capture("maxun-oss-api-key-created", {
user_id: user.id,
created_at: new Date().toISOString(),
});
return res.status(200).json({
message: "API key generated successfully",
api_key: apiKey,
});
} catch (error) { } catch (error) {
return res.status(500).json({ message: 'Error generating API key', error }); return res
.status(500)
.json({ message: "Error generating API key", error });
} }
}); }
);
router.get('/api-key', requireSignIn, async (req: AuthenticatedRequest, res) => { router.get(
"/api-key",
requireSignIn,
async (req: AuthenticatedRequest, res) => {
try { try {
if (!req.user) { if (!req.user) {
return res.status(401).json({ ok: false, error: 'Unauthorized' }); return res.status(401).json({ ok: false, error: "Unauthorized" });
} }
const user = await User.findByPk(req.user.id, { const user = await User.findByPk(req.user.id, {
raw: true, raw: true,
attributes: ['api_key'], attributes: ["api_key"],
}); });
if (!user) { if (!user) {
return res.status(404).json({ message: 'User not found' }); return res.status(404).json({ message: "User not found" });
} }
return res.status(200).json({ return res.status(200).json({
message: 'API key fetched successfully', message: "API key fetched successfully",
api_key: user.api_key || null, api_key: user.api_key || null,
}); });
} catch (error) { } catch (error) {
return res.status(500).json({ message: 'Error fetching API key', error }); return res.status(500).json({ message: "Error fetching API key", error });
} }
}); }
);
router.delete('/delete-api-key', requireSignIn, async (req: AuthenticatedRequest, res) => {
router.delete(
"/delete-api-key",
requireSignIn,
async (req: AuthenticatedRequest, res) => {
if (!req.user) { if (!req.user) {
return res.status(401).send({ error: 'Unauthorized' }); return res.status(401).send({ error: "Unauthorized" });
} }
try { try {
const user = await User.findByPk(req.user.id, { raw: true }); const user = await User.findByPk(req.user.id, { raw: true });
if (!user) { if (!user) {
return res.status(404).json({ message: 'User not found' }); return res.status(404).json({ message: "User not found" });
} }
if (!user.api_key) { if (!user.api_key) {
return res.status(404).json({ message: 'API Key not found' }); return res.status(404).json({ message: "API Key not found" });
} }
await User.update({ api_key: null }, { where: { id: req.user.id } }); await User.update({ api_key: null }, { where: { id: req.user.id } });
capture( capture("maxun-oss-api-key-deleted", {
'maxun-oss-api-key-deleted', user_id: user.id,
{ deleted_at: new Date().toISOString(),
user_id: user.id, });
deleted_at: new Date().toISOString()
}
)
return res.status(200).json({ message: 'API Key deleted successfully' }); return res.status(200).json({ message: "API Key deleted successfully" });
} catch (error: any) { } catch (error: any) {
return res.status(500).json({ message: 'Error deleting API key', error: error.message }); return res
.status(500)
.json({ message: "Error deleting API key", error: error.message });
} }
}); }
);
const oauth2Client = new google.auth.OAuth2( const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET, process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI process.env.GOOGLE_REDIRECT_URI
); );
// Step 1: Redirect to Google for authentication // Step 1: Redirect to Google for authentication
router.get('/google', (req, res) => { router.get("/google", (req, res) => {
const { robotId } = req.query; const { robotId } = req.query;
if (!robotId) { if (!robotId) {
return res.status(400).json({ message: 'Robot ID is required' }); return res.status(400).json({ message: "Robot ID is required" });
} }
const scopes = [ const scopes = [
'https://www.googleapis.com/auth/spreadsheets', "https://www.googleapis.com/auth/spreadsheets",
'https://www.googleapis.com/auth/userinfo.email', "https://www.googleapis.com/auth/userinfo.email",
'https://www.googleapis.com/auth/drive.readonly', "https://www.googleapis.com/auth/drive.readonly",
]; ];
const url = oauth2Client.generateAuthUrl({ const url = oauth2Client.generateAuthUrl({
access_type: 'offline', access_type: "offline",
prompt: 'consent', // Ensures you get a refresh token on first login prompt: "consent", // Ensures you get a refresh token on first login
scope: scopes, scope: scopes,
state: robotId.toString(), state: robotId.toString(),
}); });
res.redirect(url); res.redirect(url);
}); });
// Step 2: Handle Google OAuth callback // Step 2: Handle Google OAuth callback
router.get('/google/callback', requireSignIn, async (req: AuthenticatedRequest, res) => { router.get(
"/google/callback",
requireSignIn,
async (req: AuthenticatedRequest, res) => {
const { code, state } = req.query; const { code, state } = req.query;
try { try {
if (!state) { if (!state) {
return res.status(400).json({ message: 'Robot ID is required' }); return res.status(400).json({ message: "Robot ID is required" });
} }
const robotId = state const robotId = state;
// Get access and refresh tokens // Get access and refresh tokens
if (typeof code !== 'string') { if (typeof code !== "string") {
return res.status(400).json({ message: 'Invalid code' }); return res.status(400).json({ message: "Invalid code" });
} }
const { tokens } = await oauth2Client.getToken(code); const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens); oauth2Client.setCredentials(tokens);
// Get user profile from Google // Get user profile from Google
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client }); const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
const { data: { email } } = await oauth2.userinfo.get(); const {
data: { email },
} = await oauth2.userinfo.get();
if (!email) { if (!email) {
return res.status(400).json({ message: 'Email not found' }); return res.status(400).json({ message: "Email not found" });
} }
if (!req.user) { if (!req.user) {
return res.status(401).send({ error: 'Unauthorized' }); return res.status(401).send({ error: "Unauthorized" });
} }
// Get the currently authenticated user (from `requireSignIn`) // Get the currently authenticated user (from `requireSignIn`)
let user = await User.findOne({ where: { id: req.user.id } }); let user = await User.findOne({ where: { id: req.user.id } });
if (!user) { if (!user) {
return res.status(400).json({ message: 'User not found' }); return res.status(400).json({ message: "User not found" });
} }
let robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } }); let robot = await Robot.findOne({
where: { "recording_meta.id": robotId },
});
if (!robot) { if (!robot) {
return res.status(400).json({ message: 'Robot not found' }); return res.status(400).json({ message: "Robot not found" });
} }
robot = await robot.update({ robot = await robot.update({
google_sheet_email: email, google_sheet_email: email,
google_access_token: tokens.access_token, google_access_token: tokens.access_token,
google_refresh_token: tokens.refresh_token, google_refresh_token: tokens.refresh_token,
}); });
capture( capture("maxun-oss-google-sheet-integration-created", {
'maxun-oss-google-sheet-integration-created', user_id: user.id,
{ robot_id: robot.recording_meta.id,
user_id: user.id, created_at: new Date().toISOString(),
robot_id: robot.recording_meta.id, });
created_at: new Date().toISOString()
}
)
// List user's Google Sheets from their Google Drive // List user's Google Sheets from their Google Drive
const drive = google.drive({ version: 'v3', auth: oauth2Client }); const drive = google.drive({ version: "v3", auth: oauth2Client });
const response = await drive.files.list({ const response = await drive.files.list({
q: "mimeType='application/vnd.google-apps.spreadsheet'", // List only Google Sheets files q: "mimeType='application/vnd.google-apps.spreadsheet'", // List only Google Sheets files
fields: 'files(id, name)', // Retrieve the ID and name of each file fields: "files(id, name)", // Retrieve the ID and name of each file
}); });
const files = response.data.files || []; const files = response.data.files || [];
if (files.length === 0) { if (files.length === 0) {
return res.status(404).json({ message: 'No spreadsheets found.' }); return res.status(404).json({ message: "No spreadsheets found." });
} }
// Generate JWT token for session // Generate JWT token for session
const jwtToken = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as string, { expiresIn: '12h' }); const jwtToken = jwt.sign(
res.cookie('token', jwtToken, { httpOnly: true }); { id: user.id },
process.env.JWT_SECRET as string
);
res.cookie("token", jwtToken, { httpOnly: true });
res.json({ // res.json({
message: 'Google authentication successful', // message: 'Google authentication successful',
google_sheet_email: robot.google_sheet_email, // google_sheet_email: robot.google_sheet_email,
jwtToken, // jwtToken,
files // files
}); // });
res.cookie("robot_auth_status", "success", {
httpOnly: false,
maxAge: 60000,
}); // 1-minute expiration
res.cookie("robot_auth_message", "Robot successfully authenticated", {
httpOnly: false,
maxAge: 60000,
});
res.redirect(`http://localhost:5173`);
} catch (error: any) { } catch (error: any) {
res.status(500).json({ message: `Google OAuth error: ${error.message}` }); res.status(500).json({ message: `Google OAuth error: ${error.message}` });
} }
}); }
);
// Step 3: Get data from Google Sheets // Step 3: Get data from Google Sheets
router.post('/gsheets/data', requireSignIn, async (req: AuthenticatedRequest, res) => { router.post(
"/gsheets/data",
requireSignIn,
async (req: AuthenticatedRequest, res) => {
const { spreadsheetId, robotId } = req.body; const { spreadsheetId, robotId } = req.body;
if (!req.user) { if (!req.user) {
return res.status(401).send({ error: 'Unauthorized' }); return res.status(401).send({ error: "Unauthorized" });
} }
const user = await User.findByPk(req.user.id, { raw: true }); const user = await User.findByPk(req.user.id, { raw: true });
if (!user) { if (!user) {
return res.status(400).json({ message: 'User not found' }); return res.status(400).json({ message: "User not found" });
} }
const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId }, raw: true }); const robot = await Robot.findOne({
where: { "recording_meta.id": robotId },
raw: true,
});
if (!robot) { if (!robot) {
return res.status(400).json({ message: 'Robot not found' }); return res.status(400).json({ message: "Robot not found" });
} }
// Set Google OAuth credentials // Set Google OAuth credentials
oauth2Client.setCredentials({ oauth2Client.setCredentials({
access_token: robot.google_access_token, access_token: robot.google_access_token,
refresh_token: robot.google_refresh_token, refresh_token: robot.google_refresh_token,
}); });
const sheets = google.sheets({ version: 'v4', auth: oauth2Client }); const sheets = google.sheets({ version: "v4", auth: oauth2Client });
try { try {
// Fetch data from the spreadsheet (you can let the user choose a specific range too) // Fetch data from the spreadsheet (you can let the user choose a specific range too)
const sheetData = await sheets.spreadsheets.values.get({ const sheetData = await sheets.spreadsheets.values.get({
spreadsheetId, spreadsheetId,
range: 'Sheet1!A1:D5', // Default range, could be dynamic based on user input range: "Sheet1!A1:D5", // Default range, could be dynamic based on user input
}); });
res.json(sheetData.data); res.json(sheetData.data);
} catch (error: any) { } catch (error: any) {
res.status(500).json({ message: `Error accessing Google Sheets: ${error.message}` }); res
.status(500)
.json({ message: `Error accessing Google Sheets: ${error.message}` });
} }
}); }
);
// Step 4: Get user's Google Sheets files (new route) // Step 4: Get user's Google Sheets files (new route)
router.get('/gsheets/files', requireSignIn, async (req, res) => { router.get("/gsheets/files", requireSignIn, async (req, res) => {
try { try {
const robotId = req.query.robotId; const robotId = req.query.robotId;
const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId }, raw: true }); const robot = await Robot.findOne({
where: { "recording_meta.id": robotId },
raw: true,
});
if (!robot) { if (!robot) {
return res.status(400).json({ message: 'Robot not found' }); return res.status(400).json({ message: "Robot not found" });
}
oauth2Client.setCredentials({
access_token: robot.google_access_token,
refresh_token: robot.google_refresh_token,
});
// List user's Google Sheets files from their Google Drive
const drive = google.drive({ version: 'v3', auth: oauth2Client });
const response = await drive.files.list({
q: "mimeType='application/vnd.google-apps.spreadsheet'",
fields: 'files(id, name)',
});
const files = response.data.files || [];
if (files.length === 0) {
return res.status(404).json({ message: 'No spreadsheets found.' });
}
res.json(files);
} catch (error: any) {
console.log('Error fetching Google Sheets files:', error);
res.status(500).json({ message: `Error retrieving Google Sheets files: ${error.message}` });
} }
oauth2Client.setCredentials({
access_token: robot.google_access_token,
refresh_token: robot.google_refresh_token,
});
// List user's Google Sheets files from their Google Drive
const drive = google.drive({ version: "v3", auth: oauth2Client });
const response = await drive.files.list({
q: "mimeType='application/vnd.google-apps.spreadsheet'",
fields: "files(id, name)",
});
const files = response.data.files || [];
if (files.length === 0) {
return res.status(404).json({ message: "No spreadsheets found." });
}
res.json(files);
} catch (error: any) {
console.log("Error fetching Google Sheets files:", error);
res
.status(500)
.json({
message: `Error retrieving Google Sheets files: ${error.message}`,
});
}
}); });
// Step 5: Update robot's google_sheet_id when a Google Sheet is selected // Step 5: Update robot's google_sheet_id when a Google Sheet is selected
router.post('/gsheets/update', requireSignIn, async (req, res) => { router.post("/gsheets/update", requireSignIn, async (req, res) => {
const { spreadsheetId, spreadsheetName, robotId } = req.body; const { spreadsheetId, spreadsheetName, robotId } = req.body;
if (!spreadsheetId || !robotId) { if (!spreadsheetId || !robotId) {
return res.status(400).json({ message: 'Spreadsheet ID and Robot ID are required' }); return res
.status(400)
.json({ message: "Spreadsheet ID and Robot ID are required" });
}
try {
let robot = await Robot.findOne({
where: { "recording_meta.id": robotId },
});
if (!robot) {
return res.status(404).json({ message: "Robot not found" });
} }
try { await robot.update({
let robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } }); google_sheet_id: spreadsheetId,
google_sheet_name: spreadsheetName,
});
if (!robot) { res.json({ message: "Robot updated with selected Google Sheet ID" });
return res.status(404).json({ message: 'Robot not found' }); } catch (error: any) {
} res.status(500).json({ message: `Error updating robot: ${error.message}` });
}
await robot.update({ google_sheet_id: spreadsheetId, google_sheet_name: spreadsheetName });
res.json({ message: 'Robot updated with selected Google Sheet ID' });
} catch (error: any) {
res.status(500).json({ message: `Error updating robot: ${error.message}` });
}
}); });
router.post('/gsheets/remove', requireSignIn, async (req: AuthenticatedRequest, res) => { router.post(
"/gsheets/remove",
requireSignIn,
async (req: AuthenticatedRequest, res) => {
const { robotId } = req.body; const { robotId } = req.body;
if (!robotId) { if (!robotId) {
return res.status(400).json({ message: 'Robot ID is required' }); return res.status(400).json({ message: "Robot ID is required" });
} }
if (!req.user) { if (!req.user) {
return res.status(401).send({ error: 'Unauthorized' }); return res.status(401).send({ error: "Unauthorized" });
} }
try { try {
let robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } }); let robot = await Robot.findOne({
where: { "recording_meta.id": robotId },
});
if (!robot) { if (!robot) {
return res.status(404).json({ message: 'Robot not found' }); return res.status(404).json({ message: "Robot not found" });
} }
await robot.update({ await robot.update({
google_sheet_id: null, google_sheet_id: null,
google_sheet_name: null, google_sheet_name: null,
google_sheet_email: null, google_sheet_email: null,
google_access_token: null, google_access_token: null,
google_refresh_token: null google_refresh_token: null,
}); });
capture( capture("maxun-oss-google-sheet-integration-removed", {
'maxun-oss-google-sheet-integration-removed', user_id: req.user.id,
{ robot_id: robotId,
user_id: req.user.id, deleted_at: new Date().toISOString(),
robot_id: robotId, });
deleted_at: new Date().toISOString()
}
)
res.json({ message: 'Google Sheets integration removed successfully' }); res.json({ message: "Google Sheets integration removed successfully" });
} catch (error: any) { } catch (error: any) {
res.status(500).json({ message: `Error removing Google Sheets integration: ${error.message}` }); res
.status(500)
.json({
message: `Error removing Google Sheets integration: ${error.message}`,
});
} }
}); }
);

View File

@@ -74,8 +74,6 @@ router.get('/test', requireSignIn, async (req: AuthenticatedRequest, res: Respon
const decryptedProxyUsername = user.proxy_username ? decrypt(user.proxy_username) : null; const decryptedProxyUsername = user.proxy_username ? decrypt(user.proxy_username) : null;
const decryptedProxyPassword = user.proxy_password ? decrypt(user.proxy_password) : null; const decryptedProxyPassword = user.proxy_password ? decrypt(user.proxy_password) : null;
console.log(`Decrypted vals: ${decryptedProxyPassword}, ${decryptedProxyUrl}, ${decryptedProxyUsername}`);
const proxyOptions: any = { const proxyOptions: any = {
server: decryptedProxyUrl, server: decryptedProxyUrl,
...(decryptedProxyUsername && decryptedProxyPassword && { ...(decryptedProxyUsername && decryptedProxyPassword && {
@@ -170,8 +168,6 @@ export const getDecryptedProxyConfig = async (userId: string) => {
const decryptedProxyUsername = user.proxy_username ? decrypt(user.proxy_username) : null; const decryptedProxyUsername = user.proxy_username ? decrypt(user.proxy_username) : null;
const decryptedProxyPassword = user.proxy_password ? decrypt(user.proxy_password) : null; const decryptedProxyPassword = user.proxy_password ? decrypt(user.proxy_password) : null;
console.log(`Decrypting ${decryptedProxyUrl}, ${decryptedProxyUsername}, ${decryptedProxyPassword}`);
return { return {
proxy_url: decryptedProxyUrl, proxy_url: decryptedProxyUrl,
proxy_username: decryptedProxyUsername, proxy_username: decryptedProxyUsername,

View File

@@ -57,7 +57,6 @@ router.get('/start', requireSignIn, async (req: AuthenticatedRequest, res: Respo
} }
const id = initializeRemoteBrowserForRecording(req.user.id); const id = initializeRemoteBrowserForRecording(req.user.id);
console.log('id start:', id);
return res.send(id); return res.send(id);
}); });
@@ -70,7 +69,6 @@ router.post('/start', requireSignIn, (req: AuthenticatedRequest, res:Response) =
return res.status(401).send('User not authenticated'); return res.status(401).send('User not authenticated');
} }
const id = initializeRemoteBrowserForRecording(req.user.id); const id = initializeRemoteBrowserForRecording(req.user.id);
console.log('id start POST:', id);
return res.send(id); return res.send(id);
}); });

View File

@@ -16,6 +16,7 @@ import { workflowQueue } from '../worker';
import { AuthenticatedRequest } from './record'; import { AuthenticatedRequest } from './record';
import { computeNextRun } from '../utils/schedule'; import { computeNextRun } from '../utils/schedule';
import { capture } from "../utils/analytics"; import { capture } from "../utils/analytics";
import { tryCatch } from 'bullmq';
export const router = Router(); export const router = Router();
@@ -57,6 +58,217 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => {
} }
}) })
router.get(('/recordings/:id/runs'), requireSignIn, async (req, res) => {
try {
const runs = await Run.findAll({
where: {
robotMetaId: req.params.id
},
raw: true
});
const formattedRuns = runs.map(formatRunResponse);
const response = {
statusCode: 200,
messageCode: "success",
runs: {
totalCount: formattedRuns.length,
items: formattedRuns,
},
};
res.status(200).json(response);
} catch (error) {
console.error("Error fetching runs:", error);
res.status(500).json({
statusCode: 500,
messageCode: "error",
message: "Failed to retrieve runs",
});
}
})
function formatRunResponse(run: any) {
const formattedRun = {
id: run.id,
status: run.status,
name: run.name,
robotId: run.robotMetaId, // Renaming robotMetaId to robotId
startedAt: run.startedAt,
finishedAt: run.finishedAt,
runId: run.runId,
runByUserId: run.runByUserId,
runByScheduleId: run.runByScheduleId,
runByAPI: run.runByAPI,
data: {},
screenshot: null,
};
if (run.serializableOutput && run.serializableOutput['item-0']) {
formattedRun.data = run.serializableOutput['item-0'];
} else if (run.binaryOutput && run.binaryOutput['item-0']) {
formattedRun.screenshot = run.binaryOutput['item-0'];
}
return formattedRun;
}
/**
* 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;
// Validate input
if (!name && limit === undefined) {
return res.status(400).json({ error: 'Either "name" or "limit" must be provided.' });
}
// Fetch the robot by ID
const robot = await Robot.findOne({ where: { 'recording_meta.id': id } });
if (!robot) {
return res.status(404).json({ error: 'Robot not found.' });
}
// Update fields if provided
if (name) {
robot.set('recording_meta', { ...robot.recording_meta, name });
}
// 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) => {
if (index === 0) { // Assuming you want to update the first step
return {
...step,
what: step.what.map((action, actionIndex) => {
if (actionIndex === 0) { // Assuming the first action needs updating
return {
...action,
args: (action.args ?? []).map((arg, argIndex) => {
if (argIndex === 0) { // Assuming the first argument needs updating
return { ...arg, limit };
}
return arg;
}),
};
}
return action;
}),
};
}
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.' });
}
}
await robot.save();
const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } });
logger.log('info', `Robot with ID ${id} was updated successfully.`);
return res.status(200).json({ message: 'Robot updated successfully', robot });
} catch (error) {
// Safely handle the error type
if (error instanceof Error) {
logger.log('error', `Error updating robot with ID ${req.params.id}: ${error.message}`);
return res.status(500).json({ error: error.message });
} else {
logger.log('error', `Unknown error updating robot with ID ${req.params.id}`);
return res.status(500).json({ error: 'An unknown error occurred.' });
}
}
});
/**
* 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. * DELETE endpoint for deleting a recording from the storage.
*/ */

View File

@@ -12,47 +12,48 @@ const minioClient = new Client({
minioClient.bucketExists('maxun-test') minioClient.bucketExists('maxun-test')
.then((exists) => { .then((exists) => {
if (exists) { if (exists) {
console.log('MinIO was connected successfully.'); console.log('MinIO connected successfully.');
} else { } else {
console.log('Bucket does not exist, but MinIO was connected.'); console.log('MinIO connected successfully.');
} }
}) })
.catch((err) => { .catch((err) => {
console.error('Error connecting to MinIO:', err); console.error('Error connecting to MinIO:', err);
}) })
async function createBucketWithPolicy(bucketName: string, policy?: 'public-read' | 'private') { async function createBucketWithPolicy(bucketName: string, policy = 'public-read') {
try { try {
const bucketExists = await minioClient.bucketExists(bucketName); const bucketExists = await minioClient.bucketExists(bucketName);
if (!bucketExists) { if (!bucketExists) {
await minioClient.makeBucket(bucketName); await minioClient.makeBucket(bucketName);
console.log(`Bucket ${bucketName} created successfully.`); console.log(`Bucket ${bucketName} created successfully.`);
if (policy === 'public-read') {
// Define a public-read policy
const policyJSON = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: "",
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${bucketName}/*`]
}
]
};
await minioClient.setBucketPolicy(bucketName, JSON.stringify(policyJSON));
console.log(`Public-read policy applied to bucket ${bucketName}.`);
}
} else { } else {
console.log(`Bucket ${bucketName} already exists.`); console.log(`Bucket ${bucketName} already exists.`);
} }
if (policy === 'public-read') {
// Apply public-read policy after confirming the bucket exists
const policyJSON = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: "*",
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${bucketName}/*`]
}
]
};
await minioClient.setBucketPolicy(bucketName, JSON.stringify(policyJSON));
console.log(`Public-read policy applied to bucket ${bucketName}.`);
}
} catch (error) { } catch (error) {
console.error('Error in bucket creation or policy application:', error); console.error('Error in bucket creation or policy application:', error);
} }
} }
class BinaryOutputService { class BinaryOutputService {
private bucketName: string; private bucketName: string;

View File

@@ -6,29 +6,37 @@ export const hashPassword = (password: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
bcrypt.genSalt(12, (err, salt) => { bcrypt.genSalt(12, (err, salt) => {
if (err) { if (err) {
reject(err) reject(err);
} }
bcrypt.hash(password, salt, (err, hash) => { bcrypt.hash(password, salt, (err, hash) => {
if (err) { if (err) {
reject(err) reject(err);
} }
resolve(hash) resolve(hash);
}) });
}) });
}) });
} };
// password from frontend and hash from database // password from frontend and hash from database
export const comparePassword = (password: string, hash: string): Promise<boolean> => { export const comparePassword = (password: string, hash: string): Promise<boolean> => {
return bcrypt.compare(password, hash) return bcrypt.compare(password, hash);
} };
export const encrypt = (text: string): string => { export const encrypt = (text: string): string => {
const ivLength = 16; const ivLength = 16;
const iv = crypto.randomBytes(ivLength); const iv = crypto.randomBytes(ivLength);
const algorithm = 'aes-256-cbc'; const algorithm = 'aes-256-cbc';
const key = Buffer.from(getEnvVariable('ENCRYPTION_KEY'), 'hex');
const cipher = crypto.createCipheriv(algorithm, key, iv); // Retrieve the encryption key or generate a new one if invalid or empty
let key = getEnvVariable('ENCRYPTION_KEY');
if (!key || key.length !== 64) { // aes-256-cbc requires a 256-bit key, which is 64 hex characters
console.warn('Invalid or missing ENCRYPTION_KEY, generating a new one.');
key = crypto.randomBytes(32).toString('hex'); // Generate a new 256-bit (32-byte) key
}
const keyBuffer = Buffer.from(key, 'hex');
const cipher = crypto.createCipheriv(algorithm, keyBuffer, iv);
let encrypted = cipher.update(text, 'utf8', 'hex'); let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex'); encrypted += cipher.final('hex');
return `${iv.toString('hex')}:${encrypted}`; return `${iv.toString('hex')}:${encrypted}`;
@@ -37,9 +45,17 @@ export const encrypt = (text: string): string => {
export const decrypt = (encryptedText: string): string => { export const decrypt = (encryptedText: string): string => {
const [iv, encrypted] = encryptedText.split(':'); const [iv, encrypted] = encryptedText.split(':');
const algorithm = "aes-256-cbc"; const algorithm = "aes-256-cbc";
const key = Buffer.from(getEnvVariable('ENCRYPTION_KEY'), 'hex');
const decipher = crypto.createDecipheriv(algorithm, key, Buffer.from(iv, 'hex')); // Retrieve the encryption key or generate a new one if invalid or empty
let key = getEnvVariable('ENCRYPTION_KEY');
if (!key || key.length !== 64) { // aes-256-cbc requires a 256-bit key, which is 64 hex characters
console.warn('Invalid or missing ENCRYPTION_KEY, generating a new one.');
key = crypto.randomBytes(32).toString('hex'); // Generate a new 256-bit (32-byte) key
}
const keyBuffer = Buffer.from(key, 'hex');
const decipher = crypto.createDecipheriv(algorithm, keyBuffer, Buffer.from(iv, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8'); let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8'); decrypted += decipher.final('utf8');
return decrypted; return decrypted;
}; };

View File

@@ -5,11 +5,6 @@ import { handleRunRecording } from "./workflow-management/scheduler";
import Robot from './models/Robot'; import Robot from './models/Robot';
import { computeNextRun } from './utils/schedule'; import { computeNextRun } from './utils/schedule';
console.log('Environment variables:', {
REDIS_HOST: process.env.REDIS_HOST,
REDIS_PORT: process.env.REDIS_PORT,
});
const connection = new IORedis({ const connection = new IORedis({
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT, 10) : 6379, port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT, 10) : 6379,
@@ -68,7 +63,6 @@ console.log('Worker is running...');
async function jobCounts() { async function jobCounts() {
const jobCounts = await workflowQueue.getJobCounts(); const jobCounts = await workflowQueue.getJobCounts();
console.log('Jobs:', jobCounts);
} }
jobCounts(); jobCounts();
@@ -78,8 +72,4 @@ process.on('SIGINT', () => {
process.exit(); process.exit();
}); });
export { workflowQueue, worker }; export { workflowQueue, worker };
export const temp = () => {
console.log('temp');
}

View File

@@ -128,7 +128,6 @@ export class WorkflowGenerator {
*/ */
private registerEventHandlers = (socket: Socket) => { private registerEventHandlers = (socket: Socket) => {
socket.on('save', (data) => { socket.on('save', (data) => {
console.log('Received data:', data);
const { fileName, userId } = data; const { fileName, userId } = data;
logger.log('debug', `Saving workflow ${fileName} for user ID ${userId}`); logger.log('debug', `Saving workflow ${fileName} for user ID ${userId}`);
this.saveNewWorkflow(fileName, userId); this.saveNewWorkflow(fileName, userId);

View File

@@ -5,6 +5,11 @@ import { ScheduleSettings } from "../components/molecules/ScheduleSettings";
import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
export const getStoredRecordings = async (): Promise<string[] | null> => { export const getStoredRecordings = async (): Promise<string[] | null> => {
try { try {
const response = await axios.get(`${apiUrl}/storage/recordings`); const response = await axios.get(`${apiUrl}/storage/recordings`);
@@ -19,6 +24,36 @@ export const getStoredRecordings = async (): Promise<string[] | null> => {
} }
}; };
export const updateRecording = async (id: string, data: { name?: string; limit?: number }): Promise<boolean> => {
try {
const response = await axios.put(`${apiUrl}/storage/recordings/${id}`, data);
if (response.status === 200) {
return true;
} else {
throw new Error(`Couldn't update recording with id ${id}`);
}
} catch (error: any) {
console.error(`Error updating recording: ${error.message}`);
return false;
}
};
export const duplicateRecording = async (id: string, targetUrl: string): Promise<any> => {
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<string[] | null> => { export const getStoredRuns = async (): Promise<string[] | null> => {
try { try {
const response = await axios.get(`${apiUrl}/storage/runs`); const response = await axios.get(`${apiUrl}/storage/runs`);
@@ -47,18 +82,49 @@ export const getStoredRecording = async (id: string) => {
} }
} }
export const checkRunsForRecording = async (id: string): Promise<boolean> => {
try {
const response = await axios.get(`${apiUrl}/storage/recordings/${id}/runs`);
const runs = response.data;
console.log(runs.runs.totalCount)
return runs.runs.totalCount > 0;
} catch (error) {
console.error('Error checking runs for recording:', error);
return false;
}
};
export const deleteRecordingFromStorage = async (id: string): Promise<boolean> => { export const deleteRecordingFromStorage = async (id: string): Promise<boolean> => {
const hasRuns = await checkRunsForRecording(id);
if (hasRuns) {
return false;
}
try { try {
const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`); const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`);
if (response.status === 200) { if (response.status === 200) {
return response.data;
return true;
} else { } else {
throw new Error(`Couldn't delete stored recording ${id}`); throw new Error(`Couldn't delete stored recording ${id}`);
} }
} catch (error: any) { } catch (error: any) {
console.log(error); console.log(error);
return false; return false;
} }
}; };
export const deleteRunFromStorage = async (id: string): Promise<boolean> => { export const deleteRunFromStorage = async (id: string): Promise<boolean> => {
@@ -93,7 +159,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti
try { try {
const response = await axios.put( const response = await axios.put(
`${apiUrl}/storage/runs/${id}`, `${apiUrl}/storage/runs/${id}`,
{ ...settings }); { ...settings });
if (response.status === 200) { if (response.status === 200) {
return response.data; return response.data;
} else { } else {

View File

@@ -1,227 +1,323 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { GenericModal } from "../atoms/GenericModal"; import { GenericModal } from "../atoms/GenericModal";
import { MenuItem, Typography, CircularProgress, Alert, AlertTitle, Chip } from "@mui/material"; import {
MenuItem,
Typography,
CircularProgress,
Alert,
AlertTitle,
Chip,
} from "@mui/material";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import axios from 'axios'; import axios from "axios";
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from "../../context/globalInfo";
import { getStoredRecording } from '../../api/storage'; import { getStoredRecording } from "../../api/storage";
import { apiUrl } from '../../apiConfig.js'; import { apiUrl } from "../../apiConfig.js";
import Cookies from 'js-cookie';
interface IntegrationProps { interface IntegrationProps {
isOpen: boolean; isOpen: boolean;
handleStart: (data: IntegrationSettings) => void; handleStart: (data: IntegrationSettings) => void;
handleClose: () => void; handleClose: () => void;
} }
export interface IntegrationSettings { export interface IntegrationSettings {
spreadsheetId: string; spreadsheetId: string;
spreadsheetName: string; spreadsheetName: string;
data: string; data: string;
} }
export const IntegrationSettingsModal = ({ isOpen, handleStart, handleClose }: IntegrationProps) => { export const IntegrationSettingsModal = ({
const [settings, setSettings] = useState<IntegrationSettings>({ isOpen,
spreadsheetId: '', handleStart,
spreadsheetName: '', handleClose,
data: '', }: IntegrationProps) => {
}); const [settings, setSettings] = useState<IntegrationSettings>({
spreadsheetId: "",
spreadsheetName: "",
data: "",
});
const [spreadsheets, setSpreadsheets] = useState<{ id: string, name: string }[]>([]); const [spreadsheets, setSpreadsheets] = useState<
const [loading, setLoading] = useState(false); { id: string; name: string }[]
const [error, setError] = useState<string | null>(null); >([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { recordingId, notify } = useGlobalInfoStore(); const { recordingId, notify } = useGlobalInfoStore();
const [recording, setRecording] = useState<any>(null); const [recording, setRecording] = useState<any>(null);
const authenticateWithGoogle = () => { const authenticateWithGoogle = () => {
window.location.href = `${apiUrl}/auth/google?robotId=${recordingId}`; window.location.href = `${apiUrl}/auth/google?robotId=${recordingId}`;
}; };
const handleOAuthCallback = async () => { const handleOAuthCallback = async () => {
try { try {
const response = await axios.get(`${apiUrl}/auth/google/callback`); const response = await axios.get(`${apiUrl}/auth/google/callback`);
const { google_sheet_email, files } = response.data; const { google_sheet_email, files } = response.data;
} catch (error) { } catch (error) {
setError('Error authenticating with Google'); setError("Error authenticating with Google");
}
};
const fetchSpreadsheetFiles = async () => {
try {
const response = await axios.get(
`${apiUrl}/auth/gsheets/files?robotId=${recordingId}`,
{
withCredentials: true,
} }
}; );
setSpreadsheets(response.data);
} catch (error: any) {
console.error(
"Error fetching spreadsheet files:",
error.response?.data?.message || error.message
);
notify(
"error",
`Error fetching spreadsheet files: ${
error.response?.data?.message || error.message
}`
);
}
};
const fetchSpreadsheetFiles = async () => { const handleSpreadsheetSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
try { const selectedSheet = spreadsheets.find(
const response = await axios.get(`${apiUrl}/auth/gsheets/files?robotId=${recordingId}`, { (sheet) => sheet.id === e.target.value
withCredentials: true,
});
setSpreadsheets(response.data);
} catch (error: any) {
console.error('Error fetching spreadsheet files:', error.response?.data?.message || error.message);
notify('error', `Error fetching spreadsheet files: ${error.response?.data?.message || error.message}`);
}
};
const handleSpreadsheetSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedSheet = spreadsheets.find(sheet => sheet.id === e.target.value);
if (selectedSheet) {
setSettings({ ...settings, spreadsheetId: selectedSheet.id, spreadsheetName: selectedSheet.name });
}
};
const updateGoogleSheetId = async () => {
try {
const response = await axios.post(
`${apiUrl}/auth/gsheets/update`,
{ spreadsheetId: settings.spreadsheetId, spreadsheetName: settings.spreadsheetName, robotId: recordingId },
{ withCredentials: true }
);
console.log('Google Sheet ID updated:', response.data);
} catch (error: any) {
console.error('Error updating Google Sheet ID:', error.response?.data?.message || error.message);
}
};
const removeIntegration = async () => {
try {
await axios.post(
`${apiUrl}/auth/gsheets/remove`,
{ robotId: recordingId },
{ withCredentials: true }
);
setRecording(null);
setSpreadsheets([]);
setSettings({ spreadsheetId: '', spreadsheetName: '', data: '' });
} catch (error: any) {
console.error('Error removing Google Sheets integration:', error.response?.data?.message || error.message);
}
};
useEffect(() => {
// Check if we're on the callback URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code) {
handleOAuthCallback();
}
const fetchRecordingInfo = async () => {
if (!recordingId) return;
const recording = await getStoredRecording(recordingId);
if (recording) {
setRecording(recording);
}
};
fetchRecordingInfo();
}, [recordingId]);
return (
<GenericModal isOpen={isOpen} onClose={handleClose}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', marginLeft: '65px' }}>
<Typography variant="h6" sx={{ margin: '15px 0px' }}>Integrate with Google Sheet <Chip label="beta" color="primary" variant="outlined" /></Typography>
{recording && recording.google_sheet_id ? (
<>
<Alert severity="info">
<AlertTitle>Google Sheet Integrated Successfully.</AlertTitle>
Every time this robot creates a successful run, its captured data is appended to your {recording.google_sheet_name} Google Sheet. You can check the data updates <a href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`} target="_blank" rel="noreferrer">here</a>.
<br />
<strong>Note:</strong> The data extracted before integrating with Google Sheets will not be synced in the Google Sheet. Only the data extracted after the integration will be synced.
</Alert>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
style={{ marginTop: '15px' }}
>
Remove Integration
</Button>
</>
) : (
<>
{!recording?.google_sheet_email ? (
<>
<p>If you enable this option, every time this robot runs a task successfully, its captured data will be appended to your Google Sheet.</p>
<Button
variant="contained"
color="primary"
onClick={authenticateWithGoogle}
style={{ marginBottom: '15px' }}
>
Authenticate with Google
</Button>
</>
) : (
<>
{recording.google_sheet_email && (
<Typography sx={{ margin: '20px 0px 30px 0px' }}>
Authenticated as: {recording.google_sheet_email}
</Typography>
)}
{loading ? (
<CircularProgress sx={{ marginBottom: '15px' }} />
) : error ? (
<Typography color="error">{error}</Typography>
) : spreadsheets.length === 0 ? (
<>
<div style={{ display: 'flex', gap: '10px' }}>
<Button
variant="outlined"
color="primary"
onClick={fetchSpreadsheetFiles}
>
Fetch Google Spreadsheets
</Button>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
>
Remove Integration
</Button>
</div>
</>
) : (
<>
<TextField
sx={{ marginBottom: '15px' }}
select
label="Select Google Sheet"
required
value={settings.spreadsheetId}
onChange={handleSpreadsheetSelect}
fullWidth
>
{spreadsheets.map(sheet => (
<MenuItem key={sheet.id} value={sheet.id}>
{sheet.name}
</MenuItem>
))}
</TextField>
{settings.spreadsheetId && (
<Typography sx={{ marginBottom: '10px' }}>
Selected Sheet: {spreadsheets.find(s => s.id === settings.spreadsheetId)?.name} (ID: {settings.spreadsheetId})
</Typography>
)}
<Button
variant="contained"
color="primary"
onClick={() => {
updateGoogleSheetId();
handleStart(settings);
}}
style={{ marginTop: '10px' }}
disabled={!settings.spreadsheetId || loading}
>
Submit
</Button>
</>
)}
</>
)}
</>
)}
</div>
</GenericModal>
); );
if (selectedSheet) {
setSettings({
...settings,
spreadsheetId: selectedSheet.id,
spreadsheetName: selectedSheet.name,
});
}
};
const updateGoogleSheetId = async () => {
try {
const response = await axios.post(
`${apiUrl}/auth/gsheets/update`,
{
spreadsheetId: settings.spreadsheetId,
spreadsheetName: settings.spreadsheetName,
robotId: recordingId,
},
{ withCredentials: true }
);
notify(`success`, `Google Sheet selected successfully`)
console.log("Google Sheet ID updated:", response.data);
} catch (error: any) {
console.error(
"Error updating Google Sheet ID:",
error.response?.data?.message || error.message
);
}
};
const removeIntegration = async () => {
try {
await axios.post(
`${apiUrl}/auth/gsheets/remove`,
{ robotId: recordingId },
{ withCredentials: true }
);
setRecording(null);
setSpreadsheets([]);
setSettings({ spreadsheetId: "", spreadsheetName: "", data: "" });
} catch (error: any) {
console.error(
"Error removing Google Sheets integration:",
error.response?.data?.message || error.message
);
}
};
useEffect(() => {
// Check if there is a success message in cookies
const status = Cookies.get("robot_auth_status");
const message = Cookies.get("robot_auth_message");
if (status === "success" && message) {
notify("success", message);
// Clear the cookies after reading
Cookies.remove("robot_auth_status");
Cookies.remove("robot_auth_message");
}
// Check if we're on the callback URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
if (code) {
handleOAuthCallback();
}
const fetchRecordingInfo = async () => {
if (!recordingId) return;
const recording = await getStoredRecording(recordingId);
if (recording) {
setRecording(recording);
}
};
fetchRecordingInfo();
}, [recordingId]);
return (
<GenericModal isOpen={isOpen} onClose={handleClose} modalStyle={modalStyle}>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
marginLeft: "65px",
}}
>
<Typography variant="h6">
Integrate with Google Sheet{" "}
<Chip label="beta" color="primary" variant="outlined" />
</Typography>
{recording && recording.google_sheet_id ? (
<>
<Alert severity="info">
<AlertTitle>Google Sheet Integrated Successfully.</AlertTitle>
Every time this robot creates a successful run, its captured data
is appended to your {recording.google_sheet_name} Google Sheet.
You can check the data updates{" "}
<a
href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`}
target="_blank"
rel="noreferrer"
>
here
</a>
.
<br />
<strong>Note:</strong> The data extracted before integrating with
Google Sheets will not be synced in the Google Sheet. Only the
data extracted after the integration will be synced.
</Alert>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
style={{ marginTop: "15px" }}
>
Remove Integration
</Button>
</>
) : (
<>
{!recording?.google_sheet_email ? (
<>
<p>
If you enable this option, every time this robot runs a task
successfully, its captured data will be appended to your
Google Sheet.
</p>
<Button
variant="contained"
color="primary"
onClick={authenticateWithGoogle}
>
Authenticate with Google
</Button>
</>
) : (
<>
{recording.google_sheet_email && (
<Typography sx={{ margin: "20px 0px 30px 0px" }}>
Authenticated as: {recording.google_sheet_email}
</Typography>
)}
{loading ? (
<CircularProgress sx={{ marginBottom: "15px" }} />
) : error ? (
<Typography color="error">{error}</Typography>
) : spreadsheets.length === 0 ? (
<>
<div style={{ display: "flex", gap: "10px" }}>
<Button
variant="outlined"
color="primary"
onClick={fetchSpreadsheetFiles}
>
Fetch Google Spreadsheets
</Button>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
>
Remove Integration
</Button>
</div>
</>
) : (
<>
<TextField
sx={{ marginBottom: "15px" }}
select
label="Select Google Sheet"
required
value={settings.spreadsheetId}
onChange={handleSpreadsheetSelect}
fullWidth
>
{spreadsheets.map((sheet) => (
<MenuItem key={sheet.id} value={sheet.id}>
{sheet.name}
</MenuItem>
))}
</TextField>
{settings.spreadsheetId && (
<Typography sx={{ marginBottom: "10px" }}>
Selected Sheet:{" "}
{
spreadsheets.find(
(s) => s.id === settings.spreadsheetId
)?.name
}{" "}
(ID: {settings.spreadsheetId})
</Typography>
)}
<Button
variant="contained"
color="primary"
onClick={() => {
updateGoogleSheetId();
handleStart(settings);
}}
style={{ marginTop: "10px" }}
disabled={!settings.spreadsheetId || loading}
>
Submit
</Button>
</>
)}
</>
)}
</>
)}
</div>
</GenericModal>
);
};
export const modalStyle = {
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "50%",
backgroundColor: "background.paper",
p: 4,
height: "fit-content",
display: "block",
padding: "20px",
}; };

View File

@@ -65,21 +65,21 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
{!isRecording ? ( {!isRecording ? (
<> <>
<IconButton <IconButton
component="a" component="a"
href="https://discord.gg/NFhWDCdb" href="https://discord.gg/5GbPjBUkws"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
borderRadius: '5px', borderRadius: '5px',
padding: '8px', padding: '8px',
marginRight: '10px', marginRight: '30px',
}} }}
> >
<DiscordIcon sx={{ marginRight: '5px' }} /> <DiscordIcon sx={{ marginRight: '5px' }} />
</IconButton> </IconButton>
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe> <iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe>
<IconButton onClick={handleMenuOpen} sx={{ <IconButton onClick={handleMenuOpen} sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',

View File

@@ -9,24 +9,32 @@ import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import { useEffect } from "react"; import { useEffect } from "react";
import { WorkflowFile } from "maxun-core"; import { WorkflowFile } from "maxun-core";
import { IconButton, Button, Box, Typography, TextField } from "@mui/material";
import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power } from "@mui/icons-material";
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import { IconButton, Button, Box, Typography, TextField, MenuItem, Menu, ListItemIcon, ListItemText } from "@mui/material";
import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power, ContentCopy, } from "@mui/icons-material";
import LinkIcon from '@mui/icons-material/Link'; import LinkIcon from '@mui/icons-material/Link';
import { useGlobalInfoStore } from "../../context/globalInfo"; import { useGlobalInfoStore } from "../../context/globalInfo";
import { deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
import { Add } from "@mui/icons-material"; import { Add } from "@mui/icons-material";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { stopRecording } from "../../api/recording"; import { stopRecording } from "../../api/recording";
import { GenericModal } from '../atoms/GenericModal'; import { GenericModal } from '../atoms/GenericModal';
import axios from 'axios';
import { apiUrl } from '../../apiConfig';
import { Menu as MenuIcon } from '@mui/icons-material';
/** TODO: /** TODO:
* 1. allow editing existing robot after persisting browser steps * 1. allow editing existing robot after persisting browser steps
* 2. show robot settings: id, url, etc. * 2. show robot settings: id, url, etc.
*/ */
interface Column { interface Column {
id: 'interpret' | 'name' | 'delete' | 'schedule' | 'integrate' | 'settings'; id: 'interpret' | 'name' | 'options' | 'schedule' | 'integrate' | 'settings';
label: string; label: string;
minWidth?: number; minWidth?: number;
align?: 'right'; align?: 'right';
@@ -52,8 +60,8 @@ const columns: readonly Column[] = [
minWidth: 80, minWidth: 80,
}, },
{ {
id: 'delete', id: 'options',
label: 'Delete', label: 'Options',
minWidth: 80, minWidth: 80,
}, },
]; ];
@@ -73,17 +81,17 @@ interface RecordingsTableProps {
handleScheduleRecording: (id: string, fileName: string, params: string[]) => void; handleScheduleRecording: (id: string, fileName: string, params: string[]) => void;
handleIntegrateRecording: (id: string, fileName: string, params: string[]) => void; handleIntegrateRecording: (id: string, fileName: string, params: string[]) => void;
handleSettingsRecording: (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 }: RecordingsTableProps) => { export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => {
const [page, setPage] = React.useState(0); const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rowsPerPage, setRowsPerPage] = React.useState(10);
const [rows, setRows] = React.useState<Data[]>([]); const [rows, setRows] = React.useState<Data[]>([]);
const [isModalOpen, setModalOpen] = React.useState(false); const [isModalOpen, setModalOpen] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState(''); const [searchTerm, setSearchTerm] = React.useState('');
console.log('rows', rows);
const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore(); const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -147,11 +155,15 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
} }
}, []); }, []);
// Filter rows based on search term // Filter rows based on search term
const filteredRows = rows.filter((row) => const filteredRows = rows.filter((row) =>
row.name.toLowerCase().includes(searchTerm.toLowerCase()) row.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
return ( return (
<React.Fragment> <React.Fragment>
<Box display="flex" justifyContent="space-between" alignItems="center"> <Box display="flex" justifyContent="space-between" alignItems="center">
@@ -242,22 +254,33 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
<IntegrateButton handleIntegrate={() => handleIntegrateRecording(row.id, row.name, row.params || [])} /> <IntegrateButton handleIntegrate={() => handleIntegrateRecording(row.id, row.name, row.params || [])} />
</TableCell> </TableCell>
); );
case 'delete': case 'options':
return ( return (
<TableCell key={column.id} align={column.align}> <TableCell key={column.id} align={column.align}>
<IconButton aria-label="add" size="small" onClick={() => { <OptionsButton
deleteRecordingFromStorage(row.id).then((result: boolean) => { handleEdit={() => handleEditRobot(row.id, row.name, row.params || [])}
if (result) { handleDelete={() => {
setRows([]);
notify('success', 'Recording deleted successfully'); checkRunsForRecording(row.id).then((result: boolean) => {
fetchRecordings(); if (result) {
} notify('warning', 'Cannot delete recording as it has active runs');
}) }
}}> })
<DeleteForever />
</IconButton> deleteRecordingFromStorage(row.id).then((result: boolean) => {
</TableCell> if (result) {
); setRows([]);
notify('success', 'Recording deleted successfully');
fetchRecordings();
}
})
}}
handleDuplicate={() => {
handleDuplicateRobot(row.id, row.name, row.params || []);
}}
/>
</TableCell>
);
case 'settings': case 'settings':
return ( return (
<TableCell key={column.id} align={column.align}> <TableCell key={column.id} align={column.align}>
@@ -370,6 +393,60 @@ const SettingsButton = ({ handleSettings }: SettingsButtonProps) => {
) )
} }
interface OptionsButtonProps {
handleEdit: () => void;
handleDelete: () => void;
handleDuplicate: () => void;
}
const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsButtonProps) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
<IconButton
aria-label="options"
size="small"
onClick={handleClick}
>
<MenuIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={() => { handleEdit(); handleClose(); }}>
<ListItemIcon>
<Edit fontSize="small" />
</ListItemIcon>
<ListItemText>Edit</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDelete(); handleClose(); }}>
<ListItemIcon>
<DeleteForever fontSize="small" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDuplicate(); handleClose(); }}>
<ListItemIcon>
<ContentCopy fontSize="small" />
</ListItemIcon>
<ListItemText>Duplicate</ListItemText>
</MenuItem>
</Menu>
</>
);
};
const modalStyle = { const modalStyle = {
top: '50%', top: '50%',
left: '50%', left: '50%',

View File

@@ -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<RobotSettings | null>(null);
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
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<HTMLInputElement>) => {
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 (
<GenericModal
isOpen={isOpen}
onClose={handleClose}
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Duplicate Robot</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{
robot && (
<>
<span>Robot duplication is useful to extract data from pages with the same structure.</span>
<br />
<span>
Example: If you've created a robot for <code>producthunt.com/topics/api</code>, you can duplicate it to scrape similar pages
like <code>producthunt.com/topics/database</code> without training a robot from scratch.
</span>
<br />
<span>
<b>⚠️ Ensure the new page has the same structure as the original page.</b>
</span>
<TextField
label="Robot Target URL"
key="Robot Target URL"
value={targetUrl}
onChange={handleTargetUrlChange}
style={{ marginBottom: '20px', marginTop: '30px' }}
/>
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
<Button variant="contained" color="primary">
Duplicate Robot
</Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel
</Button>
</Box>
</>
)
}
</Box>
</>
</GenericModal>
);
};

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box, Button } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal";
import { useGlobalInfoStore } from '../../context/globalInfo';
import { getStoredRecording, updateRecording } from '../../api/storage';
import { WhereWhatPair } from 'maxun-core';
interface RobotMeta {
name: string;
id: string;
createdAt: string;
pairs: number;
updatedAt: string;
params: any[];
}
interface RobotWorkflow {
workflow: WhereWhatPair[];
}
interface RobotEditOptions {
name: string;
limit?: number;
}
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 RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
console.log("robot edit");
const [robot, setRobot] = useState<RobotSettings | null>(null);
const { recordingId, notify } = useGlobalInfoStore();
useEffect(() => {
if (isOpen) {
getRobot();
}
}, [isOpen]);
const getRobot = async () => {
if (recordingId) {
const robot = await getStoredRecording(recordingId);
setRobot(robot);
} else {
notify('error', 'Could not find robot details. Please try again.');
}
}
const handleRobotNameChange = (newName: string) => {
setRobot((prev) =>
prev ? { ...prev, recording_meta: { ...prev.recording_meta, name: newName } } : prev
);
};
const handleLimitChange = (newLimit: number) => {
setRobot((prev) => {
if (!prev) return prev;
const updatedWorkflow = [...prev.recording.workflow];
if (
updatedWorkflow.length > 0 &&
updatedWorkflow[0]?.what &&
updatedWorkflow[0].what.length > 0 &&
updatedWorkflow[0].what[0].args &&
updatedWorkflow[0].what[0].args.length > 0 &&
updatedWorkflow[0].what[0].args[0]
) {
updatedWorkflow[0].what[0].args[0].limit = newLimit;
}
return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } };
});
};
const handleSave = async () => {
if (!robot) return;
try {
const payload = {
name: robot.recording_meta.name,
limit: robot.recording.workflow[0]?.what[0]?.args?.[0]?.limit,
};
const success = await updateRecording(robot.recording_meta.id, payload);
if (success) {
notify('success', 'Robot updated successfully.');
handleStart(robot); // Inform parent about the updated robot
handleClose(); // Close the modal
window.location.reload();
} else {
notify('error', 'Failed to update the robot. Please try again.');
}
} catch (error) {
notify('error', 'An error occurred while updating the robot.');
console.error('Error updating robot:', error);
}
};
return (
<GenericModal
isOpen={isOpen}
onClose={handleClose}
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Edit Robot</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{
robot && (
<>
<TextField
label="Change Robot Name"
key="Change Robot Name"
type='text'
value={robot.recording_meta.name}
onChange={(e) => handleRobotNameChange(e.target.value)}
style={{ marginBottom: '20px' }}
/>
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
<TextField
label="Robot Limit"
type="number"
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
onChange={(e) =>
handleLimitChange(parseInt(e.target.value, 10) || 0)
}
style={{ marginBottom: '20px' }}
/>
)}
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
<Button variant="contained" color="primary">
Save Changes
</Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel
</Button>
</Box>
</>
)
}
</Box>
</>
</GenericModal>
);
};

View File

@@ -104,6 +104,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
<> <>
<TextField <TextField
label="Robot Target URL" label="Robot Target URL"
key="Robot Target URL"
value={targetUrl} value={targetUrl}
InputProps={{ InputProps={{
readOnly: true, readOnly: true,
@@ -112,6 +113,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
/> />
<TextField <TextField
label="Robot ID" label="Robot ID"
key="Robot ID"
value={robot.recording_meta.id} value={robot.recording_meta.id}
InputProps={{ InputProps={{
readOnly: true, readOnly: true,
@@ -120,7 +122,8 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
/> />
<TextField <TextField
label="Created By User" label="Created By User"
value={userEmail} key="Created By User"
value={userEmail ? userEmail : ''}
InputProps={{ InputProps={{
readOnly: true, readOnly: true,
}} }}
@@ -128,6 +131,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
/> />
<TextField <TextField
label="Robot Created At" label="Robot Created At"
key="Robot Created At"
value={robot.recording_meta.createdAt} value={robot.recording_meta.createdAt}
InputProps={{ InputProps={{
readOnly: true, readOnly: true,

View File

@@ -1,14 +1,11 @@
import { Box, Tabs, Typography, Tab, Paper } from "@mui/material"; import { Box, Tabs, Typography, Tab, Paper, Button } from "@mui/material";
import Highlight from "react-highlight"; import Highlight from "react-highlight";
import Button from "@mui/material/Button";
import * as React from "react"; import * as React from "react";
import { Data } from "./RunsTable"; import { Data } from "./RunsTable";
import { TabPanel, TabContext } from "@mui/lab"; import { TabPanel, TabContext } from "@mui/lab";
import SettingsIcon from '@mui/icons-material/Settings';
import ImageIcon from '@mui/icons-material/Image';
import ArticleIcon from '@mui/icons-material/Article'; import ArticleIcon from '@mui/icons-material/Article';
import ImageIcon from '@mui/icons-material/Image';
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import AssignmentIcon from '@mui/icons-material/Assignment';
import Table from '@mui/material/Table'; import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody'; import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell'; import TableCell from '@mui/material/TableCell';
@@ -32,7 +29,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
useEffect(() => { useEffect(() => {
setTab(tab); setTab(tab);
}, [interpretationInProgress]) }, [interpretationInProgress]);
useEffect(() => { useEffect(() => {
if (row.serializableOutput && Object.keys(row.serializableOutput).length > 0) { if (row.serializableOutput && Object.keys(row.serializableOutput).length > 0) {
@@ -47,6 +44,29 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
} }
}, [row.serializableOutput]); }, [row.serializableOutput]);
// Function to convert table data to CSV format
const convertToCSV = (data: any[], columns: string[]): string => {
const header = columns.join(',');
const rows = data.map(row =>
columns.map(col => JSON.stringify(row[col], null, 2)).join(',')
);
return [header, ...rows].join('\n');
};
// Function to download CSV file when called
const downloadCSV = () => {
const csvContent = convertToCSV(tableData, columns);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "data.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return ( return (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<TabContext value={tab}> <TabContext value={tab}>
@@ -54,7 +74,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<Tabs value={tab} onChange={(e, newTab) => setTab(newTab)} aria-label="run-content-tabs"> <Tabs value={tab} onChange={(e, newTab) => setTab(newTab)} aria-label="run-content-tabs">
<Tab label="Output Data" value='output' /> <Tab label="Output Data" value='output' />
<Tab label="Log" value='log' /> <Tab label="Log" value='log' />
{/* <Tab label="Input" value='input' /> */}
</Tabs> </Tabs>
</Box> </Box>
<TabPanel value='log'> <TabPanel value='log'>
@@ -94,16 +113,19 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<ArticleIcon sx={{ marginRight: '15px' }} /> <ArticleIcon sx={{ marginRight: '15px' }} />
Captured Data Captured Data
</Typography> </Typography>
{Object.keys(row.serializableOutput).map((key) => { <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2 }}>
return ( <Typography>
<div key={`number-of-serializable-output-${key}`}> <a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput, null, 2)}`}
<Typography sx={{ margin: '20px 0px 20px 0px' }}> download="data.json">
<a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput[key], null, 2)}`} Download as JSON
download={key}>Download as JSON</a> </a>
</Typography> </Typography>
</div> <Typography
) onClick={downloadCSV}
})} >
<a style={{ textDecoration: 'none', cursor: 'pointer' }}>Download as CSV</a>
</Typography>
</Box>
{tableData.length > 0 ? ( {tableData.length > 0 ? (
<TableContainer component={Paper} sx={{ maxHeight: 440, marginTop: 2 }}> <TableContainer component={Paper} sx={{ maxHeight: 440, marginTop: 2 }}>
<Table stickyHeader aria-label="sticky table"> <Table stickyHeader aria-label="sticky table">
@@ -139,12 +161,12 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
)} )}
</div> </div>
} }
{row.binaryOutput {row.binaryOutput && Object.keys(row.binaryOutput).length !== 0 &&
&& Object.keys(row.binaryOutput).length !== 0 &&
<div> <div>
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}> <Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
<ImageIcon sx={{ marginRight: '15px' }} /> <ImageIcon sx={{ marginRight: '15px' }} />
Captured Screenshot</Typography> Captured Screenshot
</Typography>
{Object.keys(row.binaryOutput).map((key) => { {Object.keys(row.binaryOutput).map((key) => {
try { try {
const imageUrl = row.binaryOutput[key]; const imageUrl = row.binaryOutput[key];
@@ -152,10 +174,10 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<Box key={`number-of-binary-output-${key}`} sx={{ <Box key={`number-of-binary-output-${key}`} sx={{
width: 'max-content', width: 'max-content',
}}> }}>
<Typography key={`binary-output-key-${key}`} sx={{ margin: '20px 0px 20px 0px' }}> <Typography sx={{ margin: '20px 0px' }}>
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>Download Screenshot</a> <a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>Download Screenshot</a>
</Typography> </Typography>
<img key={`image-${key}`} src={imageUrl} alt={key} height='auto' width='700px' /> <img src={imageUrl} alt={key} height='auto' width='700px' />
</Box> </Box>
) )
} catch (e) { } catch (e) {
@@ -171,4 +193,4 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
</TabContext> </TabContext>
</Box> </Box>
); );
} };

View File

@@ -63,8 +63,10 @@ export const RunsTable = (
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10); const [rowsPerPage, setRowsPerPage] = useState(10);
const [rows, setRows] = useState<Data[]>([]); const [rows, setRows] = useState<Data[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
const handleChangePage = (event: unknown, newPage: number) => { const handleChangePage = (event: unknown, newPage: number) => {
@@ -110,6 +112,7 @@ export const RunsTable = (
fetchRuns(); fetchRuns();
}; };
// Filter rows based on search term // Filter rows based on search term
const filteredRows = rows.filter((row) => const filteredRows = rows.filter((row) =>
row.name.toLowerCase().includes(searchTerm.toLowerCase()) row.name.toLowerCase().includes(searchTerm.toLowerCase())
@@ -117,6 +120,7 @@ export const RunsTable = (
// Group filtered rows by robot meta id // Group filtered rows by robot meta id
const groupedRows = filteredRows.reduce((acc, row) => { const groupedRows = filteredRows.reduce((acc, row) => {
if (!acc[row.robotMetaId]) { if (!acc[row.robotMetaId]) {
acc[row.robotMetaId] = []; acc[row.robotMetaId] = [];
} }
@@ -145,7 +149,9 @@ export const RunsTable = (
{Object.entries(groupedRows).map(([id, data]) => ( {Object.entries(groupedRows).map(([id, data]) => (
<Accordion key={id}> <Accordion key={id}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">{data[data.length - 1].name}</Typography> <Typography variant="h6">{data[data.length - 1].name}</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Table stickyHeader aria-label="sticky table"> <Table stickyHeader aria-label="sticky table">

View File

@@ -36,6 +36,10 @@ const ApiKeyManager = () => {
const [copySuccess, setCopySuccess] = useState<boolean>(false); const [copySuccess, setCopySuccess] = useState<boolean>(false);
const { notify } = useGlobalInfoStore(); const { notify } = useGlobalInfoStore();
useEffect(() => { useEffect(() => {
const fetchApiKey = async () => { const fetchApiKey = async () => {
try { try {
@@ -49,6 +53,7 @@ const ApiKeyManager = () => {
}; };
fetchApiKey(); fetchApiKey();
}, []); }, []);
const generateApiKey = async () => { const generateApiKey = async () => {
@@ -56,6 +61,7 @@ const ApiKeyManager = () => {
try { try {
const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`); const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`);
setApiKey(data.api_key); setApiKey(data.api_key);
notify('success', `Generated API Key successfully`); notify('success', `Generated API Key successfully`);
} catch (error: any) { } catch (error: any) {
notify('error', `Failed to generate API Key - ${error.message}`); notify('error', `Failed to generate API Key - ${error.message}`);

View File

@@ -1,12 +1,16 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from "react";
import styled from "styled-components"; import styled from "styled-components";
import BrowserNavBar from "../molecules/BrowserNavBar"; import BrowserNavBar from "../molecules/BrowserNavBar";
import { BrowserWindow } from "./BrowserWindow"; import { BrowserWindow } from "./BrowserWindow";
import { useBrowserDimensionsStore } from "../../context/browserDimensions"; import { useBrowserDimensionsStore } from "../../context/browserDimensions";
import { BrowserTabs } from "../molecules/BrowserTabs"; import { BrowserTabs } from "../molecules/BrowserTabs";
import { useSocketStore } from "../../context/socket"; import { useSocketStore } from "../../context/socket";
import { getCurrentTabs, getCurrentUrl, interpretCurrentRecording } from "../../api/recording"; import {
import { Box } from '@mui/material'; getCurrentTabs,
getCurrentUrl,
interpretCurrentRecording,
} from "../../api/recording";
import { Box } from "@mui/material";
import { InterpretationLog } from "../molecules/InterpretationLog"; import { InterpretationLog } from "../molecules/InterpretationLog";
// TODO: Tab !show currentUrl after recordingUrl global state // TODO: Tab !show currentUrl after recordingUrl global state
@@ -14,107 +18,125 @@ export const BrowserContent = () => {
const { width } = useBrowserDimensionsStore(); const { width } = useBrowserDimensionsStore();
const { socket } = useSocketStore(); const { socket } = useSocketStore();
const [tabs, setTabs] = useState<string[]>(['current']); const [tabs, setTabs] = useState<string[]>(["current"]);
const [tabIndex, setTabIndex] = React.useState(0); const [tabIndex, setTabIndex] = React.useState(0);
const [showOutputData, setShowOutputData] = useState(false); const [showOutputData, setShowOutputData] = useState(false);
const handleChangeIndex = useCallback((index: number) => { const handleChangeIndex = useCallback(
setTabIndex(index); (index: number) => {
}, [tabIndex]) setTabIndex(index);
},
[tabIndex]
);
const handleCloseTab = useCallback((index: number) => { const handleCloseTab = useCallback(
// the tab needs to be closed on the backend (index: number) => {
socket?.emit('closeTab', { // the tab needs to be closed on the backend
index, socket?.emit("closeTab", {
isCurrent: tabIndex === index, index,
}); isCurrent: tabIndex === index,
// change the current index as current tab gets closed });
if (tabIndex === index) { // change the current index as current tab gets closed
if (tabs.length > index + 1) { if (tabIndex === index) {
handleChangeIndex(index); if (tabs.length > index + 1) {
handleChangeIndex(index);
} else {
handleChangeIndex(index - 1);
}
} else { } else {
handleChangeIndex(index - 1); handleChangeIndex(tabIndex - 1);
} }
} else { // update client tabs
handleChangeIndex(tabIndex - 1); setTabs((prevState) => [
} ...prevState.slice(0, index),
// update client tabs ...prevState.slice(index + 1),
setTabs((prevState) => [ ]);
...prevState.slice(0, index), },
...prevState.slice(index + 1) [tabs, socket, tabIndex]
]) );
}, [tabs, socket, tabIndex]);
const handleAddNewTab = useCallback(() => { const handleAddNewTab = useCallback(() => {
// Adds new tab by pressing the plus button // Adds new tab by pressing the plus button
socket?.emit('addTab'); socket?.emit("addTab");
// Adds a new tab to the end of the tabs array and shifts focus // Adds a new tab to the end of the tabs array and shifts focus
setTabs((prevState) => [...prevState, 'new tab']); setTabs((prevState) => [...prevState, "new tab"]);
handleChangeIndex(tabs.length); handleChangeIndex(tabs.length);
}, [socket, tabs]); }, [socket, tabs]);
const handleNewTab = useCallback((tab: string) => { const handleNewTab = useCallback(
// Adds a new tab to the end of the tabs array and shifts focus (tab: string) => {
setTabs((prevState) => [...prevState, tab]); // Adds a new tab to the end of the tabs array and shifts focus
// changes focus on the new tab - same happens in the remote browser setTabs((prevState) => [...prevState, tab]);
handleChangeIndex(tabs.length); // changes focus on the new tab - same happens in the remote browser
handleTabChange(tabs.length); handleChangeIndex(tabs.length);
}, [tabs]); handleTabChange(tabs.length);
},
[tabs]
);
const handleTabChange = useCallback((index: number) => { const handleTabChange = useCallback(
// page screencast and focus needs to be changed on backend (index: number) => {
socket?.emit('changeTab', index); // page screencast and focus needs to be changed on backend
}, [socket]); socket?.emit("changeTab", index);
},
[socket]
);
const handleUrlChanged = (url: string) => { const handleUrlChanged = (url: string) => {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
if (parsedUrl.hostname) { if (parsedUrl.hostname) {
const host = parsedUrl.hostname.match(/\b(?!www\.)[a-zA-Z0-9]+/g)?.join('.') const host = parsedUrl.hostname
.match(/\b(?!www\.)[a-zA-Z0-9]+/g)
?.join(".");
if (host && host !== tabs[tabIndex]) { if (host && host !== tabs[tabIndex]) {
setTabs((prevState) => [ setTabs((prevState) => [
...prevState.slice(0, tabIndex), ...prevState.slice(0, tabIndex),
host, host,
...prevState.slice(tabIndex + 1) ...prevState.slice(tabIndex + 1),
]) ]);
} }
} else { } else {
if (tabs[tabIndex] !== 'new tab') { if (tabs[tabIndex] !== "new tab") {
setTabs((prevState) => [ setTabs((prevState) => [
...prevState.slice(0, tabIndex), ...prevState.slice(0, tabIndex),
'new tab', "new tab",
...prevState.slice(tabIndex + 1) ...prevState.slice(tabIndex + 1),
]) ]);
} }
} }
}; };
const tabHasBeenClosedHandler = useCallback((index: number) => { const tabHasBeenClosedHandler = useCallback(
handleCloseTab(index); (index: number) => {
}, [handleCloseTab]) handleCloseTab(index);
},
[handleCloseTab]
);
useEffect(() => { useEffect(() => {
if (socket) { if (socket) {
socket.on('newTab', handleNewTab); socket.on("newTab", handleNewTab);
socket.on('tabHasBeenClosed', tabHasBeenClosedHandler); socket.on("tabHasBeenClosed", tabHasBeenClosedHandler);
} }
return () => { return () => {
if (socket) { if (socket) {
socket.off('newTab', handleNewTab); socket.off("newTab", handleNewTab);
socket.off('tabHasBeenClosed', tabHasBeenClosedHandler); socket.off("tabHasBeenClosed", tabHasBeenClosedHandler);
} }
} };
}, [socket, handleNewTab]) }, [socket, handleNewTab]);
useEffect(() => { useEffect(() => {
getCurrentTabs().then((response) => { getCurrentTabs()
if (response) { .then((response) => {
setTabs(response); if (response) {
} setTabs(response);
}).catch((error) => { }
console.log("Fetching current url failed"); })
}) .catch((error) => {
}, []) console.log("Fetching current url failed");
});
}, [handleUrlChanged]);
return ( return (
<div id="browser"> <div id="browser">
@@ -134,7 +156,6 @@ export const BrowserContent = () => {
<BrowserWindow /> <BrowserWindow />
</div> </div>
); );
} };
const BrowserContentWrapper = styled.div` const BrowserContentWrapper = styled.div``;
`;

View File

@@ -8,6 +8,7 @@ import { useActionContext } from '../../context/browserActions';
import { useBrowserSteps, TextStep } from '../../context/browserSteps'; import { useBrowserSteps, TextStep } from '../../context/browserSteps';
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';
interface ElementInfo { interface ElementInfo {
tagName: string; tagName: string;
hasOnlyText?: boolean; hasOnlyText?: boolean;
@@ -316,7 +317,7 @@ export const BrowserWindow = () => {
} }
}, [paginationMode, resetPaginationSelector]); }, [paginationMode, resetPaginationSelector]);
return ( return (
<div onClick={handleClick} style={{ width: '900px' }} id="browser-window"> <div onClick={handleClick} style={{ width: '900px' }} id="browser-window">
{ {

View File

@@ -3,7 +3,7 @@ import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab'; import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { Paper, Button } from "@mui/material"; import { Paper, Button } from "@mui/material";
import { AutoAwesome, FormatListBulleted, VpnKey, Usb, Article, Link, CloudQueue } from "@mui/icons-material"; import { AutoAwesome, FormatListBulleted, VpnKey, Usb, CloudQueue, Code } from "@mui/icons-material";
import { apiUrl } from "../../apiConfig"; import { apiUrl } from "../../apiConfig";
interface MainMenuProps { interface MainMenuProps {
@@ -87,8 +87,8 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
</Tabs> </Tabs>
<hr /> <hr />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}>
<Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Article />}> <Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Code />}>
API Docs Website To API
</Button> </Button>
<Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}> <Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
Join Maxun Cloud Join Maxun Cloud
@@ -103,7 +103,7 @@ const buttonStyles = {
justifyContent: 'flex-start', justifyContent: 'flex-start',
textAlign: 'left', textAlign: 'left',
fontSize: 'medium', fontSize: 'medium',
padding: '6px 16px 6px 22px', padding: '6px 16px 6px 22px',
minHeight: '48px', minHeight: '48px',
minWidth: '100%', minWidth: '100%',
display: 'flex', display: 'flex',

View File

@@ -5,6 +5,8 @@ import { RunSettings, RunSettingsModal } from "../molecules/RunSettings";
import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings"; import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings";
import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings"; import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings";
import { RobotSettings, RobotSettingsModal } from "../molecules/RobotSettings"; import { RobotSettings, RobotSettingsModal } from "../molecules/RobotSettings";
import { RobotEditModal } from '../molecules/RobotEdit';
import { RobotDuplicationModal } from '../molecules/RobotDuplicate';
interface RecordingsProps { interface RecordingsProps {
handleEditRecording: (id: string, fileName: string) => void; handleEditRecording: (id: string, fileName: string) => void;
@@ -18,10 +20,14 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false); const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false);
const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false); const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false);
const [robotSettingsAreOpen, setRobotSettingsAreOpen] = useState(false); const [robotSettingsAreOpen, setRobotSettingsAreOpen] = useState(false);
const [robotEditAreOpen, setRobotEditAreOpen] = useState(false);
const [robotDuplicateAreOpen, setRobotDuplicateAreOpen] = useState(false);
const [params, setParams] = useState<string[]>([]); const [params, setParams] = useState<string[]>([]);
const [selectedRecordingId, setSelectedRecordingId] = useState<string>(''); const [selectedRecordingId, setSelectedRecordingId] = useState<string>('');
const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => {}; const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => {};
const handleSettingsRecording = (id: string, settings: RobotSettings) => {}; 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[]) => { const handleSettingsAndIntegrate = (id: string, name: string, params: string[]) => {
if (params.length === 0) { if (params.length === 0) {
@@ -75,6 +81,32 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
} }
} }
const handleEditRobotOption = (id: string, name: string, params: string[]) => {
if (params.length === 0) {
setRobotEditAreOpen(true);
setRecordingInfo(id, name);
setSelectedRecordingId(id);
} else {
setParams(params);
setRobotEditAreOpen(true);
setRecordingInfo(id, name);
setSelectedRecordingId(id);
}
}
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 = () => { const handleClose = () => {
setParams([]); setParams([]);
setRunSettingsAreOpen(false); setRunSettingsAreOpen(false);
@@ -103,6 +135,20 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
setSelectedRecordingId(''); setSelectedRecordingId('');
} }
const handleRobotEditClose = () => {
setParams([]);
setRobotEditAreOpen(false);
setRecordingInfo('', '');
setSelectedRecordingId('');
}
const handleRobotDuplicateClose = () => {
setParams([]);
setRobotDuplicateAreOpen(false);
setRecordingInfo('', '');
setSelectedRecordingId('');
}
return ( return (
<React.Fragment> <React.Fragment>
<RunSettingsModal isOpen={runSettingsAreOpen} <RunSettingsModal isOpen={runSettingsAreOpen}
@@ -123,6 +169,14 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
handleClose={handleRobotSettingsClose} handleClose={handleRobotSettingsClose}
handleStart={(settings) => handleSettingsRecording(selectedRecordingId, settings)} handleStart={(settings) => handleSettingsRecording(selectedRecordingId, settings)}
/> />
<RobotEditModal isOpen={robotEditAreOpen}
handleClose={handleRobotEditClose}
handleStart={(settings) => handleEditRobot(selectedRecordingId,settings)}
/>
<RobotDuplicationModal isOpen={robotDuplicateAreOpen}
handleClose={handleRobotDuplicateClose}
handleStart={(settings) => handleDuplicateRobot(selectedRecordingId, settings)}
/>
<Grid container direction="column" sx={{ padding: '30px' }}> <Grid container direction="column" sx={{ padding: '30px' }}>
<Grid item xs> <Grid item xs>
<RecordingsTable <RecordingsTable
@@ -131,6 +185,8 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
handleScheduleRecording={handleSettingsAndSchedule} handleScheduleRecording={handleSettingsAndSchedule}
handleIntegrateRecording={handleSettingsAndIntegrate} handleIntegrateRecording={handleSettingsAndIntegrate}
handleSettingsRecording={handleRobotSettings} handleSettingsRecording={handleRobotSettings}
handleEditRobot={handleEditRobotOption}
handleDuplicateRobot={handleDuplicateRobotOption}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -90,4 +90,4 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
); );
}; };
export { AuthContext, AuthProvider }; export { AuthContext, AuthProvider };

View File

@@ -28,7 +28,8 @@ a {
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace; monospace;
color: #ff00c3;
} }
#browser-actions { #browser-actions {
@@ -53,9 +54,6 @@ code {
transform-origin: top left; /* Keep the position fixed */ transform-origin: top left; /* Keep the position fixed */
} }
#browser {
}
#browser-window { #browser-window {
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;

View File

@@ -1,119 +1,134 @@
import axios from 'axios'; import axios from "axios";
import { useState, useContext, useEffect } from 'react'; import { useState, useContext, useEffect, FormEvent } from "react";
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from "react-router-dom";
import { AuthContext } from '../context/auth'; import { AuthContext } from "../context/auth";
import { import { Box, Typography, TextField, Button, CircularProgress, Grid } from "@mui/material";
Box,
Typography,
TextField,
Button,
CircularProgress,
} from '@mui/material';
import { useGlobalInfoStore } from "../context/globalInfo"; import { useGlobalInfoStore } from "../context/globalInfo";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
const Login = () => { const Login = () => {
const [form, setForm] = useState({ const [form, setForm] = useState({
email: '', email: "",
password: '', password: "",
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { notify } = useGlobalInfoStore(); const { notify } = useGlobalInfoStore();
const { email, password } = form; const { email, password } = form;
const { state, dispatch } = useContext(AuthContext); const { state, dispatch } = useContext(AuthContext);
const { user } = state; const { user } = state;
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (user) { if (user) {
navigate('/'); navigate("/");
} }
}, [user, navigate]); }, [user, navigate]);
const handleChange = (e: any) => { const handleChange = (e: any) => {
const { name, value } = e.target; const { name, value } = e.target;
setForm({ ...form, [name]: value }); setForm({ ...form, [name]: value });
}; };
const submitForm = async (e: any) => { const submitForm = async (e: any) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
try { try {
const { data } = await axios.post(`${apiUrl}/auth/login`, { email, password }); const { data } = await axios.post(`${apiUrl}/auth/login`, {
dispatch({ type: 'LOGIN', payload: data }); email,
notify('success', 'Welcome to Maxun!'); password,
window.localStorage.setItem('user', JSON.stringify(data)); });
navigate('/'); dispatch({ type: "LOGIN", payload: data });
} catch (err: any) { notify("success", "Welcome to Maxun!");
notify('error', err.response.data || 'Login Failed. Please try again.'); window.localStorage.setItem("user", JSON.stringify(data));
setLoading(false); navigate("/");
} } catch (err) {
}; notify("error", "Login Failed. Please try again.");
setLoading(false);
}
};
return ( return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
maxHeight: "100vh",
mt: 6,
padding: 4,
}}
>
<Box <Box
sx={{ component="form"
display: 'flex', onSubmit={submitForm}
flexDirection: 'column', sx={{
alignItems: 'center', textAlign: "center",
mt: 5, backgroundColor: "#ffffff",
}} padding: 6,
borderRadius: 5,
boxShadow: "0px 20px 40px rgba(0, 0, 0, 0.2), 0px -5px 10px rgba(0, 0, 0, 0.15)",
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: 400,
width: "100%",
}}
> >
<Typography variant="h4" gutterBottom> <img src="../src/assets/maxunlogo.png" alt="logo" height={55} width={60} style={{ marginBottom: 20, borderRadius: "20%", alignItems: "center" }} />
Welcome Back! <Typography variant="h4" gutterBottom>
</Typography> Welcome Back!
<Box component="form" onSubmit={submitForm} sx={{ maxWidth: 400, width: '100%' }}> </Typography>
<TextField <TextField
fullWidth fullWidth
label="Email" label="Email"
name="email" name="email"
value={email} value={email}
onChange={handleChange} onChange={handleChange}
margin="normal" margin="normal"
variant="outlined" variant="outlined"
required required
/> />
<TextField <TextField
fullWidth fullWidth
label="Password" label="Password"
name="password" name="password"
type="password" type="password"
value={password} value={password}
onChange={handleChange} onChange={handleChange}
margin="normal" margin="normal"
variant="outlined" variant="outlined"
required required
/> />
<Button
<Button type="submit"
type="submit" fullWidth
fullWidth variant="contained"
variant="contained" color="primary"
color="primary" sx={{ mt: 2, mb: 2 }}
sx={{ mt: 2, mb: 2 }} disabled={loading || !email || !password}
disabled={loading || !email || !password} >
> {loading ? (
{loading ? ( <>
<> <CircularProgress size={20} sx={{ mr: 2 }} />
<CircularProgress size={20} sx={{ mr: 2 }} /> Loading
Loading </>
</> ) : (
) : ( "Login"
'Login' )}
)} </Button>
</Button> <Typography variant="body2" align="center">
Dont have an account?{" "}
<Typography variant="body2" align="center"> <Link to="/register" style={{ textDecoration: "none", color: "#ff33cc" }}>
Dont have an account?{' '} Register
<Link to="/register" style={{ textDecoration: 'none'}}> </Link>
Register </Typography>
</Link>
</Typography>
</Box>
</Box> </Box>
); </Box>
);
}; };
export default Login; export default Login;

View File

@@ -1,117 +1,132 @@
import { useState, useContext, useEffect } from 'react'; import axios from "axios";
import { useNavigate, Link } from 'react-router-dom'; import { useState, useContext, useEffect } from "react";
import axios from 'axios'; import { useNavigate, Link } from "react-router-dom";
import { AuthContext } from '../context/auth'; import { AuthContext } from "../context/auth";
import { TextField, Button, CircularProgress, Typography, Box, Container } from '@mui/material'; import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material";
import { useGlobalInfoStore } from "../context/globalInfo"; import { useGlobalInfoStore } from "../context/globalInfo";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
const Register = () => { const Register = () => {
const [form, setForm] = useState({ const [form, setForm] = useState({
email: '', email: "",
password: '', password: "",
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { notify } = useGlobalInfoStore(); const { notify } = useGlobalInfoStore();
const { email, password } = form; const { email, password } = form;
const { state, dispatch } = useContext(AuthContext); const { state, dispatch } = useContext(AuthContext);
const { user } = state; const { user } = state;
const navigate = useNavigate();
useEffect(() => { const navigate = useNavigate();
if (user !== null) navigate('/');
}, [user, navigate]);
const handleChange = (e: any) => { useEffect(() => {
const { name, value } = e.target; if (user) {
setForm({ ...form, [name]: value }); navigate("/");
}; }
}, [user, navigate]);
const submitForm = async (e: any) => { const handleChange = (e: any) => {
e.preventDefault(); const { name, value } = e.target;
setLoading(true); setForm({ ...form, [name]: value });
try { };
const { data } = await axios.post(`${apiUrl}/auth/register`, {
email,
password,
});
dispatch({
type: 'LOGIN',
payload: data,
});
notify('success', 'Welcome to Maxun!');
window.localStorage.setItem('user', JSON.stringify(data));
navigate('/');
} catch (err: any) {
notify('error', err.response.data || 'Registration Failed. Please try again.');
} finally {
setLoading(false);
}
};
return ( const submitForm = async (e: any) => {
<Box e.preventDefault();
sx={{ setLoading(true);
display: 'flex', try {
flexDirection: 'column', const { data } = await axios.post(`${apiUrl}/auth/register`, {
alignItems: 'center', email,
mt: 5, password,
}} });
dispatch({ type: "LOGIN", payload: data });
notify("success", "Registration Successful!");
window.localStorage.setItem("user", JSON.stringify(data));
navigate("/");
} catch (error:any) {
notify("error", error.response.data || "Registration Failed. Please try again.");
setLoading(false);
}
};
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
maxHeight: "100vh",
mt: 6,
padding: 4,
}}
>
<Box
component="form"
onSubmit={submitForm}
sx={{
textAlign: "center",
backgroundColor: "#ffffff",
padding: 6,
borderRadius: 5,
boxShadow: "0px 20px 40px rgba(0, 0, 0, 0.2), 0px -5px 10px rgba(0, 0, 0, 0.15)",
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: 400,
width: "100%",
}}
>
<img src="../src/assets/maxunlogo.png" alt="logo" height={55} width={60} style={{ marginBottom: 20, borderRadius: "20%", alignItems: "center" }} />
<Typography variant="h4" gutterBottom>
Create an Account
</Typography>
<TextField
fullWidth
label="Email"
name="email"
value={email}
onChange={handleChange}
margin="normal"
variant="outlined"
required
/>
<TextField
fullWidth
label="Password"
name="password"
type="password"
value={password}
onChange={handleChange}
margin="normal"
variant="outlined"
required
/>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
sx={{ mt: 2, mb: 2 }}
disabled={loading || !email || !password}
> >
<Typography variant="h4" gutterBottom> {loading ? (
Create an account <>
</Typography> <CircularProgress size={20} sx={{ mr: 2 }} />
<Box component="form" onSubmit={submitForm} sx={{ maxWidth: 400, width: '100%' }}> Loading
<TextField </>
margin="normal" ) : (
required "Register"
fullWidth )}
id="email" </Button>
label="Email Address" <Typography variant="body2" align="center">
name="email" Already have an account?{" "}
value={email} <Link to="/login" style={{ textDecoration: "none", color: "#ff33cc" }}>
onChange={handleChange} Login
autoComplete="email" </Link>
/> </Typography>
<TextField </Box>
margin="normal" </Box>
required );
fullWidth
name="password"
label="Password"
type="password"
id="password"
value={password}
onChange={handleChange}
autoComplete="current-password"
/>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
sx={{ mt: 3, mb: 2 }}
disabled={loading || !email || !password}
>
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 2 }} />
Loading
</>
) : (
'Register'
)}
</Button>
<Typography variant="body2" align="center">
Already have an account?{' '}
<Link to="/login" style={{ textDecoration: 'none' }}>
Login
</Link>
</Typography>
</Box>
</Box>
);
}; };
export default Register; export default Register;