From 077a2ddc35fdfc23658e0c5243ac6fdb1fcecb53 Mon Sep 17 00:00:00 2001 From: nguyentrungthat <80239428+nguentrungthat@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:39:39 +0700 Subject: [PATCH] Update --- BACKEND/app/controllers/auth_controller.ts | 42 +++ BACKEND/app/controllers/users_controller.ts | 11 +- BACKEND/app/middleware/auth_middleware.ts | 2 +- BACKEND/app/models/line.ts | 6 +- BACKEND/app/services/line_connection.ts | 56 +++- BACKEND/app/ultils/helper.ts | 14 + BACKEND/package-lock.json | 2 +- BACKEND/package.json | 2 +- BACKEND/providers/socket_io_provider.ts | 91 ++++-- BACKEND/start/routes.ts | 6 + FRONTEND/index.html | 2 +- FRONTEND/package-lock.json | 27 +- FRONTEND/package.json | 4 +- FRONTEND/src/App.tsx | 277 ++++++++++++------ .../AuthenticationImage.module.css | 70 +++++ .../Authentication/ChangePassword.tsx | 189 ++++++++++++ .../src/components/Authentication/Login.tsx | 181 ++++++++++++ .../components/Authentication/Register.tsx | 126 ++++++++ .../Authentication/ResetPassword.module.css | 4 + FRONTEND/src/components/CardLine.tsx | 62 +++- FRONTEND/src/components/Component.module.css | 4 +- FRONTEND/src/components/TerminalXTerm.tsx | 236 +++++++++++++++ 22 files changed, 1258 insertions(+), 156 deletions(-) create mode 100644 BACKEND/app/controllers/auth_controller.ts create mode 100644 BACKEND/app/ultils/helper.ts create mode 100644 FRONTEND/src/components/Authentication/AuthenticationImage.module.css create mode 100644 FRONTEND/src/components/Authentication/ChangePassword.tsx create mode 100644 FRONTEND/src/components/Authentication/Login.tsx create mode 100644 FRONTEND/src/components/Authentication/Register.tsx create mode 100644 FRONTEND/src/components/Authentication/ResetPassword.module.css create mode 100644 FRONTEND/src/components/TerminalXTerm.tsx diff --git a/BACKEND/app/controllers/auth_controller.ts b/BACKEND/app/controllers/auth_controller.ts new file mode 100644 index 0000000..c1fdc9d --- /dev/null +++ b/BACKEND/app/controllers/auth_controller.ts @@ -0,0 +1,42 @@ +import type { HttpContext } from '@adonisjs/core/http' +import User from '../models/user.js' + +export default class AuthController { + // Đăng ký + async register({ request, response }: HttpContext) { + const data = request.only(['email', 'password', 'full_name']) + const user = await User.create(data) + return response.json({ message: 'User created', user }) + } + + // Đăng nhập + async login({ request, auth, response }: HttpContext) { + const { email, password } = request.only(['email', 'password']) + const user = await User.query().where('email', email).first() + + if (!user) { + return response.status(401).json({ message: 'Invalid email or password' }) + } + + try { + // So sánh password + if (user.password !== password) { + return response.status(401).json({ message: 'Invalid email or password' }) + } + + // ✅ Nếu dùng token thủ công: + const token = Math.random().toString(36).substring(2) // hoặc JWT nếu bạn cài auth + return response.json({ + message: 'Login successful', + user: { id: user.id, email: user.email, token }, + }) + } catch { + return response.status(401).json({ message: 'Invalid credentials' }) + } + } + + // Đăng xuất + async logout({ auth, response }: HttpContext) { + return response.json({ message: 'Logged out successfully' }) + } +} diff --git a/BACKEND/app/controllers/users_controller.ts b/BACKEND/app/controllers/users_controller.ts index ea044cc..88730d9 100644 --- a/BACKEND/app/controllers/users_controller.ts +++ b/BACKEND/app/controllers/users_controller.ts @@ -1,6 +1,5 @@ import User from '#models/user' import type { HttpContext } from '@adonisjs/core/http' -import hash from '@adonisjs/core/services/hash' export default class UsersController { async index({ request, response }: HttpContext) { @@ -37,13 +36,10 @@ export default class UsersController { }) } - // Hash the password before saving - const hashedPassword = await hash.make(data.password) - const user = await User.create({ fullName: data.full_name, email: data.email, - password: hashedPassword, + password: data.password, }) return response.created({ @@ -73,11 +69,6 @@ export default class UsersController { } } - // Hash the password if it is provided - if (data.password) { - data.password = await hash.make(data.password) - } - user.merge(data) await user.save() diff --git a/BACKEND/app/middleware/auth_middleware.ts b/BACKEND/app/middleware/auth_middleware.ts index 6e07003..f5a2ba3 100644 --- a/BACKEND/app/middleware/auth_middleware.ts +++ b/BACKEND/app/middleware/auth_middleware.ts @@ -22,4 +22,4 @@ export default class AuthMiddleware { await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo }) return next() } -} \ No newline at end of file +} diff --git a/BACKEND/app/models/line.ts b/BACKEND/app/models/line.ts index db2d03b..d298ac2 100644 --- a/BACKEND/app/models/line.ts +++ b/BACKEND/app/models/line.ts @@ -12,16 +12,16 @@ export default class Line extends BaseModel { declare port: number @column() - declare line_number: number + declare lineNumber: number @column() - declare line_clear: number + declare lineClear: number @column() declare stationId: number @column() - declare apc_name: number + declare apcName: string @column() declare outlet: number diff --git a/BACKEND/app/services/line_connection.ts b/BACKEND/app/services/line_connection.ts index 5e37160..af8416b 100644 --- a/BACKEND/app/services/line_connection.ts +++ b/BACKEND/app/services/line_connection.ts @@ -1,4 +1,5 @@ import net from 'node:net' +import { cleanData } from '../ultils/helper.js' interface LineConfig { id: number @@ -7,6 +8,8 @@ interface LineConfig { ip: string stationId: number apcName?: string + output: string + status: string } export default class LineConnection { @@ -20,12 +23,19 @@ export default class LineConnection { this.client = new net.Socket() } - connect() { + connect(timeoutMs = 5000) { return new Promise((resolve, reject) => { const { ip, port, lineNumber, id, stationId } = this.config + let resolvedOrRejected = false + // Set timeout + this.client.setTimeout(timeoutMs) this.client.connect(port, ip, () => { + if (resolvedOrRejected) return + resolvedOrRejected = true + console.log(`✅ Connected to line ${lineNumber} (${ip}:${port})`) + this.config.status = 'connected' this.socketIO.emit('line_connected', { stationId, lineId: id, @@ -36,8 +46,19 @@ export default class LineConnection { }) this.client.on('data', (data) => { - const message = data.toString().trim() - console.log(`📨 [${this.config.apcName}] ${message}`) + let message = data.toString() + // let output = cleanData(message) + // console.log(`📨 [${this.config.port}] ${message}`) + // Handle netOutput with backspace support + for (const char of message) { + if (char === '\x7F' || char === '\x08') { + this.config.output = this.config.output.slice(0, -1) + // message = message.slice(0, -1) + } else { + this.config.output += cleanData(char) + } + } + this.config.output = this.config.output.slice(-15000) this.socketIO.emit('line_output', { stationId, lineId: id, @@ -46,7 +67,10 @@ export default class LineConnection { }) this.client.on('error', (err) => { + if (resolvedOrRejected) return + resolvedOrRejected = true console.error(`❌ Error line ${lineNumber}:`, err.message) + this.config.output += err.message this.socketIO.emit('line_error', { stationId, lineId: id, @@ -57,12 +81,23 @@ export default class LineConnection { this.client.on('close', () => { console.log(`🔌 Line ${lineNumber} disconnected`) + this.config.status = 'disconnected' this.socketIO.emit('line_disconnected', { stationId, lineId: id, lineNumber, + status: 'disconnected', }) }) + + this.client.on('timeout', () => { + if (resolvedOrRejected) return + resolvedOrRejected = true + + console.log(`⏳ Connection timeout line ${lineNumber}`) + this.client.destroy() + reject(new Error('Connection timeout')) + }) }) } @@ -71,13 +106,26 @@ export default class LineConnection { console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`) return } - console.log(`➡️ [${this.config.apcName}] SEND:`, cmd) + // console.log(`➡️ [${this.config.apcName}] SEND:`, cmd) this.client.write(`${cmd}\r\n`) } + writeCommand(cmd: string) { + if (this.client.destroyed) { + console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`) + return + } + this.client.write(`${cmd}`) + } + disconnect() { try { this.client.destroy() + this.config.status = 'disconnected' + this.socketIO.emit('line_disconnected', { + ...this.config, + status: 'disconnected', + }) console.log(`🔻 Closed connection to line ${this.config.lineNumber}`) } catch (e) { console.error('Error closing line:', e) diff --git a/BACKEND/app/ultils/helper.ts b/BACKEND/app/ultils/helper.ts new file mode 100644 index 0000000..4f311df --- /dev/null +++ b/BACKEND/app/ultils/helper.ts @@ -0,0 +1,14 @@ +/** + * Function to clean up unwanted characters from the output data. + * @param {string} data - The raw data to be cleaned. + * @returns {string} - The cleaned data. + */ +export const cleanData = (data) => { + return data + .replace(/--More--\s*BS\s*BS\s*BS\s*BS\s*BS\s*BS/g, '') + .replace(/\s*--More--\s*/g, '') + .replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI escape codes + .replace(/\x08/g, '') + .replace(/[^\x20-\x7E\r\n]/g, '') // Remove non-printable characters + // .replace(/\r\n/g, '\n') +} diff --git a/BACKEND/package-lock.json b/BACKEND/package-lock.json index af9900f..e950352 100644 --- a/BACKEND/package-lock.json +++ b/BACKEND/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "UNLICENSED", "dependencies": { - "@adonisjs/auth": "^9.4.0", + "@adonisjs/auth": "^9.5.1", "@adonisjs/core": "^6.18.0", "@adonisjs/cors": "^2.2.1", "@adonisjs/lucid": "^21.6.1", diff --git a/BACKEND/package.json b/BACKEND/package.json index 2151c3f..9fad319 100644 --- a/BACKEND/package.json +++ b/BACKEND/package.json @@ -51,7 +51,7 @@ "typescript": "~5.8" }, "dependencies": { - "@adonisjs/auth": "^9.4.0", + "@adonisjs/auth": "^9.5.1", "@adonisjs/core": "^6.18.0", "@adonisjs/cors": "^2.2.1", "@adonisjs/lucid": "^21.6.1", diff --git a/BACKEND/providers/socket_io_provider.ts b/BACKEND/providers/socket_io_provider.ts index 584c5ca..823b6ae 100644 --- a/BACKEND/providers/socket_io_provider.ts +++ b/BACKEND/providers/socket_io_provider.ts @@ -4,6 +4,7 @@ import LineConnection from '../app/services/line_connection.js' import { ApplicationService } from '@adonisjs/core/types' import env from '#start/env' import { CustomServer, CustomSocket } from '../app/ultils/types.js' +import Line from '#models/line' interface Station { id: number @@ -77,24 +78,37 @@ export class WebSocketIo { console.log('Socket connected:', socket.id) socket.connectionTime = new Date() + const lineConnectionArray: LineConnection[] = Array.from(this.lineMap.values()) + io.to(socket.id).emit( + 'init', + lineConnectionArray.map((el) => el.config) + ) + socket.on('disconnect', () => { - console.log(`🔴 FE disconnected: ${socket.id}`) + console.log(`FE disconnected: ${socket.id}`) }) // FE gửi yêu cầu connect lines - socket.on('connect_lines', async (stationData: Station) => { - console.log('📡 Yêu cầu connect station:', stationData.name) - await this.connectStation(socket, stationData) + socket.on('connect_lines', async (data) => { + const { stationData, linesData } = data + await this.connectLine(io, linesData, stationData) }) - // FE gửi command đến line cụ thể - socket.on('send_command', (data) => { - const { lineId, command } = data - const line = this.lineMap.get(lineId) - if (line) { - line.sendCommand(command) - } else { - socket.emit('line_error', { lineId, error: 'Line not connected' }) + socket.on('write_command_line_from_web', (data) => { + const { lineIds, stationId, command } = data + for (const lineId of lineIds) { + const line = this.lineMap.get(lineId) + if (line) { + this.setTimeoutConnect(lineId, line) + line.writeCommand(command) + } else { + io.emit('line_disconnected', { + stationId, + lineId, + status: 'disconnected', + }) + io.emit('line_error', { lineId, error: 'Line not connected\r\n' }) + } } }) @@ -111,22 +125,30 @@ export class WebSocketIo { return io } - private async connectStation(socket, station: Station) { - this.stationMap.set(station.id, station) - for (const line of station.lines) { - const lineConn = new LineConnection( - { - id: line.id, - port: line.port, - ip: station.ip, - lineNumber: line.lineNumber, - stationId: station.id, - apcName: line.apcName, - }, - this.io - ) - await lineConn.connect() - this.lineMap.set(line.id, lineConn) + private async connectLine(socket: any, lines: Line[], station: Station) { + try { + this.stationMap.set(station.id, station) + for (const line of lines) { + const lineConn = new LineConnection( + { + id: line.id, + port: line.port, + ip: station.ip, + lineNumber: line.lineNumber, + stationId: station.id, + apcName: line.apcName, + output: '', + status: '', + }, + socket + ) + await lineConn.connect() + lineConn.writeCommand('\r\n\r\n') + this.lineMap.set(line.id, lineConn) + this.setTimeoutConnect(line.id, lineConn) + } + } catch (error) { + console.log(error) } } @@ -145,4 +167,17 @@ export class WebSocketIo { this.stationMap.delete(stationId) console.log(`🔻 Station ${station.name} disconnected`) } + + private setTimeoutConnect = (lineId: number, lineConn: LineConnection) => { + if (this.intervalMap[`${lineId}`]) { + clearInterval(this.intervalMap[`${lineId}`]) + delete this.intervalMap[`${lineId}`] + } + const interval = setInterval(() => { + lineConn.disconnect() + this.lineMap.delete(lineId) + }, 120000) + + this.intervalMap[`${lineId}`] = interval + } } diff --git a/BACKEND/start/routes.ts b/BACKEND/start/routes.ts index 80d36fd..5a9a9b1 100644 --- a/BACKEND/start/routes.ts +++ b/BACKEND/start/routes.ts @@ -63,3 +63,9 @@ router router.post('delete', '#controllers/scenarios_controller.delete') }) .prefix('api/scenarios') + +router + .group(() => { + router.post('/login', '#controllers/auth_controller.login') + }) + .prefix('api/auth') diff --git a/FRONTEND/index.html b/FRONTEND/index.html index 072a57e..a65cfb7 100644 --- a/FRONTEND/index.html +++ b/FRONTEND/index.html @@ -4,7 +4,7 @@ - frontend + Automation Test
diff --git a/FRONTEND/package-lock.json b/FRONTEND/package-lock.json index 707d862..92c0a2c 100644 --- a/FRONTEND/package-lock.json +++ b/FRONTEND/package-lock.json @@ -12,10 +12,12 @@ "@mantine/dates": "^8.3.5", "@mantine/notifications": "^8.3.5", "@tabler/icons-react": "^3.35.0", + "@xterm/addon-fit": "^0.10.0", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "xterm": "^5.3.0" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -1913,6 +1915,22 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT", + "peer": true + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4280,6 +4298,13 @@ "node": ">=0.4.0" } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/FRONTEND/package.json b/FRONTEND/package.json index b9770b2..da42df5 100644 --- a/FRONTEND/package.json +++ b/FRONTEND/package.json @@ -14,10 +14,12 @@ "@mantine/dates": "^8.3.5", "@mantine/notifications": "^8.3.5", "@tabler/icons-react": "^3.35.0", + "@xterm/addon-fit": "^0.10.0", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "xterm": "^5.3.0" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/FRONTEND/src/App.tsx b/FRONTEND/src/App.tsx index 9e3275f..230da99 100644 --- a/FRONTEND/src/App.tsx +++ b/FRONTEND/src/App.tsx @@ -21,14 +21,16 @@ import type { TLine, TStation } from "./untils/types"; import axios from "axios"; import CardLine from "./components/CardLine"; import { IconEdit, IconSettingsPlus } from "@tabler/icons-react"; +import { SocketProvider, useSocket } from "./context/SocketContext"; const apiUrl = import.meta.env.VITE_BACKEND_URL; /** * Main Component */ -export default function App() { +export function App() { document.title = "Automation Test"; + const { socket } = useSocket(); const [stations, setStations] = useState([]); const [selectedLines, setSelectedLines] = useState([]); const [activeTab, setActiveTab] = useState("0"); @@ -62,95 +64,162 @@ export default function App() { getStation(); }, []); - return ( - - - {/* Tabs (Top Bar) */} - setActiveTab(id?.toString() || "0")} - variant="none" - keepMounted={false} - > - - {stations.map((station) => ( - - {station.name} - - ))} + useEffect(() => { + if (!socket || !stations?.length) return; - - - - - + const updateStatus = (data: any) => { + const line = getLine(data.lineId, data.stationId); + if (line) { + updateValueLineStation(line, "status", data.status); + } + }; + + socket.on("line_connected", updateStatus); + socket.on("line_disconnected", updateStatus); + socket?.on("init", (data) => { + if (Array.isArray(data)) { + data.forEach((value) => { + updateStatus(value); + }); + } + }); + + // ✅ cleanup on unmount or when socket changes + return () => { + socket.off("line_connected"); + socket.off("line_disconnected"); + socket.off("line_disconnected"); + }; + }, [socket, stations]); + + const updateValueLineStation = ( + currentLine: TLine, + field: string, + value: any + ) => { + setStations((el) => + el?.map((station: TStation) => + station.id.toString() === activeTab + ? { + ...station, + lines: (station?.lines || [])?.map((lineItem: TLine) => { + if (lineItem.id === currentLine.id) { + return { + ...lineItem, + [field]: value, + }; + } + return lineItem; + }), + } + : station + ) + ); + }; + + const getLine = (lineId: number, stationId: number) => { + const station = stations?.find((sta) => sta.id === stationId); + if (station) { + const line = station.lines?.find((li) => li.id === lineId); + return line; + } else return null; + }; + + return ( + + {/* Tabs (Top Bar) */} + setActiveTab(id?.toString() || "0")} + variant="none" + keepMounted={false} + > + + {stations.map((station) => ( + + {station.name} + + ))} + + + + + + + {Number(activeTab) && ( - - + )} + + - {stations.map((station) => ( - - - ( + + + + { + const el = document.querySelector( + ".mantine-ScrollArea-viewport" + ); + if (!el) return; + const maxScroll = el.scrollHeight - el.clientHeight; + setShowBottomShadow(y < maxScroll - 2); }} > - { - const el = document.querySelector( - ".mantine-ScrollArea-viewport" - ); - if (!el) return; - const maxScroll = el.scrollHeight - el.clientHeight; - setShowBottomShadow(y < maxScroll - 2); - }} - > - {station.lines.length > 0 ? ( - - {[ - ...station.lines, - ...station.lines, - ...station.lines, - ].map((line) => ( - - ))} - - ) : ( - - No lines configured - - )} - - - 0 ? ( + + {station.lines.map((line, i) => ( + + ))} + + ) : ( + + No lines configured + + )} + + + + + + + + + ))} + + + ); +} + +export default function Main() { + return ( + + + + ); } diff --git a/FRONTEND/src/components/Authentication/AuthenticationImage.module.css b/FRONTEND/src/components/Authentication/AuthenticationImage.module.css new file mode 100644 index 0000000..9abac98 --- /dev/null +++ b/FRONTEND/src/components/Authentication/AuthenticationImage.module.css @@ -0,0 +1,70 @@ +.wrapper { + min-height: rem(100vh); + background-size: cover; + background-image: url(https://images.unsplash.com/photo-1484242857719-4b9144542727?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1280&q=80); + } + + .form { + border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7)); + min-height: rem(100vh); + max-width: rem(500px); + padding-top: rem(80px); + @media (max-width: var(--mantine-breakpoint-sm)) { + max-width: 100%; + }; + font-weight: 600; + } + + .title { + color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); + font-family: + Greycliff CF, + var(--mantine-font-family); + } + + .google-btn { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #4285f4; + border-radius: 4px; + background-color: white; + margin: 15px auto; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: background-color 0.3s ease; + } + + .google-btn:hover { + background-color: #f0f0f0; + } + + .google-btn:active { + background-color: #e0e0e0; + } + + .google-icon { + width: 20px; + height: 20px; + margin-right: 10px; + } + + .title { + position: relative; + font-size: 1.5rem; + font-weight: 600; + color: #222; + text-align: center; + margin-bottom: 1.5rem; +} + +.title::after { + content: ""; + display: block; + width: 200px; + height: 2px; + background-color: #007bff; /* blue accent */ + margin: 0.1rem auto 0; + border-radius: 3px; +} \ No newline at end of file diff --git a/FRONTEND/src/components/Authentication/ChangePassword.tsx b/FRONTEND/src/components/Authentication/ChangePassword.tsx new file mode 100644 index 0000000..f625935 --- /dev/null +++ b/FRONTEND/src/components/Authentication/ChangePassword.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react' +import { useSelector } from 'react-redux' +import { RootState } from '@/rtk/store' + +import { + Box, + Button, + Modal, + PasswordInput, + Text, + TextInput, +} from '@mantine/core' +import { notifications } from '@mantine/notifications' + +import PasswordRequirementInput from '../PasswordRequirementInput/PasswordRequirementInput' +import { requirementsPassword } from '@/rtk/helpers/variables' +import { passwordRegex } from '@/utils/formRegexs' + +import { post } from '@/rtk/helpers/apiService' +import { changePassword } from '@/api/Auth' + +type TChangePassword = { + opened: boolean + setOpened: React.Dispatch> +} + +function ChangePassword({ opened, setOpened }: TChangePassword) { + const { user } = useSelector((state: RootState) => state.auth) + + const [countSpam, setCountSpam] = useState(0) + const [loading, setLoading] = useState(false) + const [formData, setFormData] = useState({ + current_password: '', + password: '', + confirm_password: '', + }) + + const handleChangePassword = async () => { + setLoading(true) + if (countSpam > 5) { + notifications.show({ + title: 'Error', + message: 'Password error more than 5 times. Logout after 3s', + color: 'red', + }) + + setTimeout(() => { + localStorage.clear() + window.location.reload() + }, 3000) + + return + } + + try { + const res = await post(changePassword, { + email: user!.email, + current_password: formData.current_password, + password: formData.password, + confirm_password: formData.confirm_password, + }) + + if (res.status) { + notifications.show({ + title: 'Success', + message: res.message, + color: 'green', + }) + + setOpened(false) + setFormData({ + current_password: '', + password: '', + confirm_password: '', + }) + return + } + setCountSpam(countSpam + 1) + } catch (error: any) { + console.log(error) + } finally { + setLoading(false) + } + } + + const isFormValid = + formData.current_password && + formData.password && + passwordRegex.test(formData.password) && + formData.password === formData.confirm_password + + return ( + { + setOpened(false) + setFormData({ + current_password: '', + password: '', + confirm_password: '', + }) + }} + title={ + + Change password + + } + > + +
{ + e.preventDefault() + handleChangePassword() + }} + > + + + { + setFormData({ + ...formData, + current_password: e.target.value, + }) + }} + mb="md" + error={ + formData.current_password.length < 8 && + formData.current_password !== '' && + 'Length 8 characters or more' + } + /> + + + + + setFormData({ + ...formData, + confirm_password: e.target.value, + }) + } + error={ + formData.password !== formData.confirm_password && + formData.confirm_password !== '' && + 'Password do not match' + } + /> + + + +
+
+ ) +} + +export default ChangePassword diff --git a/FRONTEND/src/components/Authentication/Login.tsx b/FRONTEND/src/components/Authentication/Login.tsx new file mode 100644 index 0000000..fd5febc --- /dev/null +++ b/FRONTEND/src/components/Authentication/Login.tsx @@ -0,0 +1,181 @@ +import { useGoogleLogin } from '@react-oauth/google' +import { emailRegex } from '@/utils/formRegexs' + +import { useDispatch, useSelector } from 'react-redux' +import { AppDispatch, RootState } from '@/rtk/store' +import { + loginAsync, + loginERPAsync, + loginWithGoogleAsync, +} from '@/rtk/slices/authSlice' + +import { Box, Button, PasswordInput, TextInput } from '@mantine/core' +import { useForm } from '@mantine/form' + +import classes from './AuthenticationImage.module.css' +import { useNavigate } from 'react-router-dom' + +import ImgERP from '../../lib/images/erp.jpg' +import { useState } from 'react' + +type TLogin = { + email: string + password: string +} + +const Login = () => { + const navigate = useNavigate() + const dispatch = useDispatch() + const { status } = useSelector((state: RootState) => state.auth) + const [isLoginERP, setIsLoginERP] = useState(false) + + const formLogin = useForm({ + initialValues: { + email: '', + password: '', + }, + validate: (values) => ({ + email: + values.email === '' + ? 'Email is required' + : isLoginERP + ? null + : emailRegex.test(values.email) + ? null + : 'Invalid email', + + password: values.password === '' ? 'Password is required' : null, + }), + }) + + const handleLogin = async (values: TLogin) => { + if (isLoginERP) { + const payload = { + userEmail: values.email, + password: values.password, + } + const resultAction = await dispatch(loginERPAsync(payload)) + + if (loginERPAsync.fulfilled.match(resultAction)) { + // set interval to wait for localStorage to be set + window.location.href = '/dashboard' + } + } else { + const resultAction = await dispatch(loginAsync(values)) + + if (loginAsync.fulfilled.match(resultAction)) { + navigate('/dashboard') + } + } + } + + const handleLoginGG = useGoogleLogin({ + onSuccess: async (codeResponse) => { + const accessToken = codeResponse.access_token + const resultAction = await dispatch(loginWithGoogleAsync(accessToken)) + + if (loginWithGoogleAsync.fulfilled.match(resultAction)) { + navigate('/dashboard') + } + }, + onError: (error) => console.log('Login Failed:', error), + }) + + return ( +
+
+

+ {isLoginERP ? 'Login with ERP account' : 'Login with ATC account'} +

+
+ { + formLogin.setFieldValue('email', e.target.value!) + }} + required + size="md" + /> + { + formLogin.setFieldValue('password', e.target.value!) + }} + required + mt="md" + size="md" + /> + + + + {!isLoginERP ? ( + + + + ) : ( + + + + )} + + + + + + ) +} + +export default Login diff --git a/FRONTEND/src/components/Authentication/Register.tsx b/FRONTEND/src/components/Authentication/Register.tsx new file mode 100644 index 0000000..dee2796 --- /dev/null +++ b/FRONTEND/src/components/Authentication/Register.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { AppDispatch, RootState } from '@/rtk/store' +import { registerAsync } from '@/rtk/slices/authSlice' + +import PasswordRequirementInput from '../PasswordRequirementInput/PasswordRequirementInput' +import { emailRegex, passwordRegex } from '@/utils/formRegexs' +import { requirementsPassword } from '@/rtk/helpers/variables' + +import { Box, Button, PasswordInput, TextInput } from '@mantine/core' + +type TRegister = { + email: string + password: string + confirm_password: string + full_name: string +} + +function Register() { + const dispatch = useDispatch() + const { status } = useSelector((state: RootState) => state.auth) + + const [formRegister, setFormRegister] = useState({ + email: '', + full_name: '', + password: '', + confirm_password: '', + }) + + const handleRegister = async () => { + // Dispatch action registerAsync với dữ liệu form và đợi kết quả + const resultAction = await dispatch(registerAsync(formRegister)) + + // Kiểm tra nếu action thành công + if (registerAsync.fulfilled.match(resultAction)) { + // Tải lại trang web + // window.location.reload() + } +} + + return ( +
{ + e.preventDefault() + handleRegister() + }} + > + { + setFormRegister({ ...formRegister, email: e.target.value }) + }} + required + size="md" + mb="md" + /> + + { + setFormRegister({ ...formRegister, full_name: e.target.value }) + }} + required + size="md" + /> + + + + { + setFormRegister({ ...formRegister, confirm_password: e.target.value }) + }} + required + size="md" + /> + + + + + + ) +} + +export default Register diff --git a/FRONTEND/src/components/Authentication/ResetPassword.module.css b/FRONTEND/src/components/Authentication/ResetPassword.module.css new file mode 100644 index 0000000..ccb7aff --- /dev/null +++ b/FRONTEND/src/components/Authentication/ResetPassword.module.css @@ -0,0 +1,4 @@ +.resetForm { + background-color: light-dark(#ffffffdb, var(--mantine-color-dark-5)); + border-radius: 8px; +} diff --git a/FRONTEND/src/components/CardLine.tsx b/FRONTEND/src/components/CardLine.tsx index 58c6a6e..3d21454 100644 --- a/FRONTEND/src/components/CardLine.tsx +++ b/FRONTEND/src/components/CardLine.tsx @@ -1,15 +1,23 @@ import { Card, Text, Box, Flex } from "@mantine/core"; -import type { TLine } from "../untils/types"; +import type { TLine, TStation } from "../untils/types"; import classes from "./Component.module.css"; +import TerminalCLI from "./TerminalXTerm"; +import type { Socket } from "socket.io-client"; +import { IconCircleCheckFilled } from "@tabler/icons-react"; +import { memo } from "react"; const CardLine = ({ line, selectedLines, setSelectedLines, + socket, + stationItem, }: { line: TLine; selectedLines: TLine[]; setSelectedLines: (lines: React.SetStateAction) => void; + socket: Socket | null; + stationItem: TStation; }) => { return ( [...pre, line]); }} > - - -
- - Line {line.lineNumber} - {line.port} - -
- PID: WS-C3560CG-8PC-S + +
+ + Line {line.lineNumber} - {line.port}{" "} + {line.status === "connected" && ( + + )} + +
+ {/* PID: WS-C3560CG-8PC-S
SN: FGL2240307M
-
VID: V01
-
+
VID: V01
*/} { e.preventDefault(); e.stopPropagation(); }} - style={{ backgroundColor: "black", height: "130px", width: "220px" }} - > + style={{ height: "175px", width: "300px" }} + > + {}} + /> +
{/*