diff --git a/package.json b/package.json index 054f4c282..a259636fc 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { - "name": "@coreui/coreui-free-react-admin-template", - "version": "5.2.0", - "description": "CoreUI Free React Admin Template", + "name": "EnglishDen school", + "version": "2.0.0", + "description": "EnglishDen School management software", "homepage": ".", "bugs": { "url": "https://github.com/coreui/coreui-free-react-admin-template/issues" }, "repository": { "type": "git", - "url": "git@github.com:coreui/coreui-free-react-admin-template.git" + "url": "git@github.com:gpimthong/coreui.git" }, "license": "MIT", - "author": "The CoreUI Team (https://github.com/orgs/coreui/people)", + "author": "Govit Pimthong", "scripts": { "build": "vite build", "lint": "eslint \"src/**/*.js\"", @@ -27,10 +27,12 @@ "@coreui/react-chartjs": "^3.0.0", "@coreui/utils": "^2.0.2", "@popperjs/core": "^2.11.8", + "axios": "^1.7.9", "chart.js": "^4.4.6", "classnames": "^2.5.1", "core-js": "^3.39.0", "prop-types": "^15.8.1", + "qrcode.react": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.1.2", diff --git a/src/App.js b/src/App.js index f5b22393e..c5e9a0916 100644 --- a/src/App.js +++ b/src/App.js @@ -7,6 +7,9 @@ import './scss/style.scss' // We use those styles to show code examples, you should remove them in your application. import './scss/examples.scss' +import TwoFactorVerification from './views/pages/login/TwoFactorVerification' +import TwoFactorSetupAndVerification from './views/pages/login/OTPVerify' +import Logout from './views/pages/logout/Logout' // Containers const DefaultLayout = React.lazy(() => import('./layout/DefaultLayout')) @@ -46,6 +49,9 @@ const App = () => { > } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/_nav.js b/src/_nav.js index 9f8ca150b..94b1cd344 100644 --- a/src/_nav.js +++ b/src/_nav.js @@ -13,6 +13,8 @@ import { cilPuzzle, cilSpeedometer, cilStar, + cilBank, + cilAccountLogout, } from '@coreui/icons' import { CNavGroup, CNavItem, CNavTitle } from '@coreui/react' @@ -21,11 +23,21 @@ const _nav = [ component: CNavItem, name: 'Dashboard', to: '/dashboard', - icon: , - badge: { + icon: , +/* badge: { color: 'info', text: 'NEW', - }, + }, */ + }, + { + component: CNavItem, + name: 'Logout', + to: '/logout', + icon: , +/* badge: { + color: 'info', + text: 'NEW', + }, */ }, { component: CNavTitle, diff --git a/src/api/api.js b/src/api/api.js new file mode 100644 index 000000000..62eeab785 --- /dev/null +++ b/src/api/api.js @@ -0,0 +1,270 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import { cibWindows } from '@coreui/icons'; + +const API_BASE_URL = 'http://10.10.7.83:8000/'; + + + +// Create an Axios instance +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Function to get tokens from storage (localStorage/sessionStorage) +const getTokens = () => ({ + access: localStorage.getItem('access_token'), + refresh: localStorage.getItem('refresh_token'), +}); + +// Function to save tokens to storage +const saveTokens = ({ access, refresh }) => { + localStorage.setItem('access_token', access); + localStorage.setItem('refresh_token', refresh); +}; + +// Function to remove tokens from storage +const clearTokens = () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); +}; + +// Add a request interceptor +api.interceptors.request.use( + (config) => { + const { access } = getTokens(); + if (access) { + // If the access token is found, set it in the Authorization header + config.headers.Authorization = `Bearer ${access}`; + } else { + // If no access token is found, redirect to login + window.location.href = '#/login'; + } + return config; + }, + (error) => Promise.reject(error) +); + +// Add a response interceptor +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // Check if error is due to an expired access token + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; // Prevent retry loop + const { refresh } = getTokens(); + + if (refresh) { + try { + // Attempt to refresh the access token + const response = await axios.post(`${API_BASE_URL}api/token/refresh/`, { refresh }); + const { access } = response.data; + saveTokens({ access, refresh }); // Update tokens in storage + + // Retry the original request with the new access token + originalRequest.headers.Authorization = `Bearer ${access}`; + return api(originalRequest); + } catch (refreshError) { + clearTokens(); // Clear tokens if refresh fails + return Promise.reject(refreshError); + } + } + } + + return Promise.reject(error); + } +); + + + + +const useFetchOne = (model, id) => { + const [data, setData] = useState(null); // Start with null for better clarity + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); // Ensure loading is reset if dependencies change + try { + const url = `api/v1/RestAPI/?model_name=${model}&id=${id}`; + const response = await api.get(url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${model} with ID ${id}: ${response.statusText}`); + } + + const result = await response.json(); + setData(result); + } catch (err) { + setError(err.message || 'An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [model, id]); + + return { data, loading, error }; +}; + +const useTableData = (model, skipKeys = [], patternsToModify = []) => { + const [tableData, setTableData] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + + + const default_page_size = 5; + + const response = await api.get('api/v1/RestAPI/?model_name=' + model + '&page_size=' + default_page_size); + + const data = response.data.results; + + const total_items = response.data.count; + const total_pages = response.data.count; + const current_page = response.data.current_page; + const next_url = response.data.next; //can be null + const prev_url = response.data.previous; //can be null + + + //console.log(response.data); + + const processedData = { + columns: Object.keys(data[0]) + .filter((key) => !skipKeys.includes(key)) + .map((key) => ({ + key, + label: patternsToModify + .reduce((modifiedKey, { pattern, replacement }) => { + return modifiedKey.replace(pattern, replacement); + }, key) + .replace(/_/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()), + _props: { scope: 'col' }, + })), + items: data.map((item) => { + const row = { _cellProps: { id: { scope: 'row' } } }; + Object.keys(item) + .filter((key) => !skipKeys.includes(key)) + .forEach((key) => { + let value = item[key]; + const datePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/; + if (typeof value === 'string' && datePattern.test(value)) { + value = new Date(value).toLocaleString(); + } + row[key] = value; + }); + return row; + }), + }; + + setTableData(processedData); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + return { tableData, loading, error }; +}; + + + + + +const useTablePaginatedData = (model, skipKeys = [], patternsToModify = [], defaultPageSize = 5) => { + const [tableData, setTableData] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pagination, setPagination] = useState({ + currentPage: 1, + pageSize: defaultPageSize, + totalItems: 0, + totalPages: 0, + }); + + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const response = await api.get( + `api/v1/RestAPI/?model_name=${model}&page_size=${pagination.pageSize}&page=${pagination.currentPage}` + ); + + const data = response.data.results; + //console.log(data); + + setTableData({ + columns: Object.keys(data[0]) + .filter((key) => !skipKeys.includes(key)) + .map((key) => ({ + key, + label: patternsToModify + .reduce((modifiedKey, { pattern, replacement }) => { + return modifiedKey.replace(pattern, replacement); + }, key) + .replace(/_/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()), + _props: { scope: 'col' }, + })), + items: data.map((item) => { + const row = { _cellProps: { id: { scope: 'row' } } }; + Object.keys(item) + .filter((key) => !skipKeys.includes(key)) + .forEach((key) => { + let value = item[key]; + const datePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/; + if (typeof value === 'string' && datePattern.test(value)) { + value = new Date(value).toLocaleString(); + } + row[key] = value; + }); + return row; + }), + }); + + setPagination({ + ...pagination, + totalItems: response.data.count, + totalPages: Math.ceil(response.data.count / pagination.pageSize), + }); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [model, pagination.currentPage, pagination.pageSize]); + + return { tableData, loading, error, pagination, setPagination }; +}; + + +export { + useTableData, + useTablePaginatedData, + useFetchOne, + API_BASE_URL, + api, + saveTokens, + getTokens, + clearTokens, +} diff --git a/src/components/AppContent.js b/src/components/AppContent.js index b9a39ef50..e0d90dbfe 100644 --- a/src/components/AppContent.js +++ b/src/components/AppContent.js @@ -7,7 +7,7 @@ import routes from '../routes' const AppContent = () => { return ( - + }> {routes.map((route, idx) => { diff --git a/src/components/AppHeader.js b/src/components/AppHeader.js index b10bd7e12..5bc642378 100644 --- a/src/components/AppHeader.js +++ b/src/components/AppHeader.js @@ -131,9 +131,9 @@ const AppHeader = () => { - + {/* - + */} ) } diff --git a/src/components/table/Pagination.js b/src/components/table/Pagination.js new file mode 100644 index 000000000..2a3cdc1e0 --- /dev/null +++ b/src/components/table/Pagination.js @@ -0,0 +1,129 @@ +import { CPagination, CPaginationItem, CFormSelect, CRow, CCol } from '@coreui/react'; + +const Pagination = ({ pagination, onPageChange, onPageSizeChange, maxVisiblePages = 7 }) => { + const { currentPage, totalPages, pageSize } = pagination; + + const getPageNumbers = () => { + const pages = []; + const half = Math.floor((maxVisiblePages - 1) / 2); + + if (totalPages <= maxVisiblePages) { + // Case 1: Show all pages if total pages are less than maxVisiblePages + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else if (currentPage <= half + 1) { + // Case 2: Current page near the start + for (let i = 1; i <= maxVisiblePages - 2; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } else if (currentPage >= totalPages - half) { + // Case 3: Current page near the end + pages.push(1); + pages.push('...'); + for (let i = totalPages - (maxVisiblePages - 3); i <= totalPages; i++) { + pages.push(i); + } + } else { + // Case 4: Current page in the middle + pages.push(1); + pages.push('...'); + for (let i = currentPage - half + 2; i <= currentPage + half - 2; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } + + return pages; + }; + + const handlePageChange = (page) => { + if (page > 0 && page <= totalPages && page !== currentPage) { + onPageChange(page); + } + }; + + const handlePageSizeChange = (e) => { + const newSize = parseInt(e.target.value, 10); + onPageSizeChange(newSize); + }; + + const pages = getPageNumbers(); + + return ( + + + + + + + {[5, 10, 20, 50, 100].map((size) => ( + + ))} + + + + + {/* Pagination controls */} + + handlePageChange(currentPage - 1)} + style={{ + cursor: currentPage === 1 ? 'not-allowed' : 'pointer', + opacity: currentPage === 1 ? 0.6 : 1, + }} + > + + + + {pages.map((page, index) => + page === '...' ? ( + + ... + + ) : ( + handlePageChange(page)} + style={{ + cursor: currentPage === page ? 'default' : 'pointer', + }} + > + {page} + + ) + )} + + handlePageChange(currentPage + 1)} + style={{ + cursor: currentPage === totalPages ? 'not-allowed' : 'pointer', + opacity: currentPage === totalPages ? 0.6 : 1, + }} + > + + + + + + ); +}; + +export default Pagination; diff --git a/src/components/table/TableWithPagination.js b/src/components/table/TableWithPagination.js new file mode 100644 index 000000000..fbb07481b --- /dev/null +++ b/src/components/table/TableWithPagination.js @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { useTablePaginatedData } from '../../api/api'; +import Pagination from './Pagination'; +import { + CTable, + CTableHead, + CTableRow, + CTableHeaderCell, + CTableBody, + CTableDataCell, + CAvatar, + CProgress, +} from '@coreui/react'; + +const TableWithPagination = () => { + const model = 'appconfiguration'; + + const skipKeys = [ + 'type', + 'status', + 'created', + 'updated', + 'updated_by', + 'data', + 'comment_hub', + 'file_hub', + 'gen_hub', + 'comment_hub_for_display', + 'file_hub_for_display', + 'gen_hub_for_display', + ]; + + const patternsToModify = [ + { pattern: /_for_display/, replacement: '' }, + ]; + + const { tableData, loading, error, pagination, setPagination } = useTablePaginatedData(model, skipKeys, patternsToModify); + + const handlePageChange = (page) => { + setPagination((prev) => ({ ...prev, currentPage: page })); + }; + + const handlePageSizeChange = (pageSize) => { + setPagination((prev) => ({ ...prev, pageSize: pageSize, currentPage: 1})); + }; + + return ( +
+ {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( + <> +
+ {/*
*/} + + {/*
*/} + +
+ {/* */} +
+
+ + + )} +
+ ); +}; + +export default TableWithPagination; diff --git a/src/components/table/appconfiguration.js b/src/components/table/appconfiguration.js new file mode 100644 index 000000000..dea0baed9 --- /dev/null +++ b/src/components/table/appconfiguration.js @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; +import { + CTable, + CTableHead, + CTableRow, + CTableHeaderCell, + CTableBody, + CTableDataCell, + CAvatar, + CProgress, +} from '@coreui/react'; + +import { useTableData, useFetchOne } from '../../api/api'; + +const TableComponent = () => { + const skipKeys = [ + 'type', + 'status', + 'created', + 'updated', + 'updated_by', + 'data', + 'comment_hub', + 'file_hub', + 'gen_hub', + ]; + + const patternsToModify = [ + { pattern: /_for_display/, replacement: '' }, + ]; + + const { tableData, loading, error } = useTableData( + 'userconfiguration', + skipKeys, + patternsToModify + ); + + if (loading) { + return
Loading...
; + } + + if (error) { + return
Error: {error}
; + } + + return ; +}; + +export default TableComponent; \ No newline at end of file diff --git a/src/routes.js b/src/routes.js index d2e9d6479..447ed9323 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,6 +1,6 @@ import React from 'react' -const Dashboard = React.lazy(() => import('./views/dashboard/Dashboard')) +const Dashboard = React.lazy(() => import('./views/dashboard/myDashboard')) const Colors = React.lazy(() => import('./views/theme/colors/Colors')) const Typography = React.lazy(() => import('./views/theme/typography/Typography')) diff --git a/src/views/dashboard/myDashboard.js b/src/views/dashboard/myDashboard.js new file mode 100644 index 000000000..e4947c1e8 --- /dev/null +++ b/src/views/dashboard/myDashboard.js @@ -0,0 +1,188 @@ +import React from 'react' +import classNames from 'classnames' + +import { + CAvatar, + CButton, + CButtonGroup, + CCard, + CCardBody, + CCardFooter, + CCardHeader, + CCol, + CProgress, + CRow, + CTable, + CTableBody, + CTableDataCell, + CTableHead, + CTableHeaderCell, + CTableRow, +} from '@coreui/react' +import CIcon from '@coreui/icons-react' +import { + cibCcAmex, + cibCcApplePay, + cibCcMastercard, + cibCcPaypal, + cibCcStripe, + cibCcVisa, + cibGoogle, + cibFacebook, + cibLinkedin, + cifBr, + cifEs, + cifFr, + cifIn, + cifPl, + cifUs, + cibTwitter, + cilCloudDownload, + cilPeople, + cilUser, + cilUserFemale, +} from '@coreui/icons' + +import avatar1 from 'src/assets/images/avatars/1.jpg' +import avatar2 from 'src/assets/images/avatars/2.jpg' +import avatar3 from 'src/assets/images/avatars/3.jpg' +import avatar4 from 'src/assets/images/avatars/4.jpg' +import avatar5 from 'src/assets/images/avatars/5.jpg' +import avatar6 from 'src/assets/images/avatars/6.jpg' + +import WidgetsBrand from '../widgets/WidgetsBrand' +import WidgetsDropdown from '../widgets/WidgetsDropdown' +import MainChart from './MainChart' +import TableComponent from '../../components/table/appconfiguration' +import TableWithPagination from '../../components/table/TableWithPagination' +const Dashboard = () => { + const progressExample = [ + { title: 'Visits', value: '29.703 Users', percent: 40, color: 'success' }, + { title: 'Unique', value: '24.093 Users', percent: 20, color: 'info' }, + { title: 'Pageviews', value: '78.706 Views', percent: 60, color: 'warning' }, + { title: 'New Users', value: '22.123 Users', percent: 80, color: 'danger' }, + { title: 'Bounce Rate', value: 'Average Rate', percent: 40.15, color: 'primary' }, + ] + + const progressGroupExample1 = [ + { title: 'Monday', value1: 34, value2: 78 }, + { title: 'Tuesday', value1: 56, value2: 94 }, + { title: 'Wednesday', value1: 12, value2: 67 }, + { title: 'Thursday', value1: 43, value2: 91 }, + { title: 'Friday', value1: 22, value2: 73 }, + { title: 'Saturday', value1: 53, value2: 82 }, + { title: 'Sunday', value1: 9, value2: 69 }, + ] + + const progressGroupExample2 = [ + { title: 'Male', icon: cilUser, value: 53 }, + { title: 'Female', icon: cilUserFemale, value: 43 }, + ] + + const progressGroupExample3 = [ + { title: 'Organic Search', icon: cibGoogle, percent: 56, value: '191,235' }, + { title: 'Facebook', icon: cibFacebook, percent: 15, value: '51,223' }, + { title: 'Twitter', icon: cibTwitter, percent: 11, value: '37,564' }, + { title: 'LinkedIn', icon: cibLinkedin, percent: 8, value: '27,319' }, + ] + + const tableExample = [ + { + avatar: { src: avatar1, status: 'success' }, + user: { + name: 'Yiorgos Avraamu', + new: true, + registered: 'Jan 1, 2023', + }, + country: { name: 'USA', flag: cifUs }, + usage: { + value: 50, + period: 'Jun 11, 2023 - Jul 10, 2023', + color: 'success', + }, + payment: { name: 'Mastercard', icon: cibCcMastercard }, + activity: '10 sec ago', + }, + { + avatar: { src: avatar2, status: 'danger' }, + user: { + name: 'Avram Tarasios', + new: false, + registered: 'Jan 1, 2023', + }, + country: { name: 'Brazil', flag: cifBr }, + usage: { + value: 22, + period: 'Jun 11, 2023 - Jul 10, 2023', + color: 'info', + }, + payment: { name: 'Visa', icon: cibCcVisa }, + activity: '5 minutes ago', + }, + { + avatar: { src: avatar3, status: 'warning' }, + user: { name: 'Quintin Ed', new: true, registered: 'Jan 1, 2023' }, + country: { name: 'India', flag: cifIn }, + usage: { + value: 74, + period: 'Jun 11, 2023 - Jul 10, 2023', + color: 'warning', + }, + payment: { name: 'Stripe', icon: cibCcStripe }, + activity: '1 hour ago', + }, + { + avatar: { src: avatar4, status: 'secondary' }, + user: { name: 'Enéas Kwadwo', new: true, registered: 'Jan 1, 2023' }, + country: { name: 'France', flag: cifFr }, + usage: { + value: 98, + period: 'Jun 11, 2023 - Jul 10, 2023', + color: 'danger', + }, + payment: { name: 'PayPal', icon: cibCcPaypal }, + activity: 'Last month', + }, + { + avatar: { src: avatar5, status: 'success' }, + user: { + name: 'Agapetus Tadeáš', + new: true, + registered: 'Jan 1, 2023', + }, + country: { name: 'Spain', flag: cifEs }, + usage: { + value: 22, + period: 'Jun 11, 2023 - Jul 10, 2023', + color: 'primary', + }, + payment: { name: 'Google Wallet', icon: cibCcApplePay }, + activity: 'Last week', + }, + { + avatar: { src: avatar6, status: 'danger' }, + user: { + name: 'Friderik Dávid', + new: true, + registered: 'Jan 1, 2023', + }, + country: { name: 'Poland', flag: cifPl }, + usage: { + value: 43, + period: 'Jun 11, 2023 - Jul 10, 2023', + color: 'success', + }, + payment: { name: 'Amex', icon: cibCcAmex }, + activity: 'Last week', + }, + ] + + return ( + <> + + + + ) +} + +export default Dashboard diff --git a/src/views/pages/login/Login.js b/src/views/pages/login/Login.js index 1b2ee0baa..c9fcc6984 100644 --- a/src/views/pages/login/Login.js +++ b/src/views/pages/login/Login.js @@ -1,5 +1,5 @@ -import React from 'react' -import { Link } from 'react-router-dom' +import React, { useState, useEffect } from 'react' +import { Link, UNSAFE_NavigationContext } from 'react-router-dom' import { CButton, CCard, @@ -15,24 +15,74 @@ import { } from '@coreui/react' import CIcon from '@coreui/icons-react' import { cilLockLocked, cilUser } from '@coreui/icons' +import { useNavigate } from 'react-router-dom' +import { api, saveTokens } from '../../../api/api' const Login = () => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + const response = await api.post('api/token/', { username, password }); + + // Assuming the API returns 'access' and 'refresh' tokens + const { access, refresh, requires_2fa, requires_gen_otp} = response.data; + + // Store tokens (use localStorage or other methods as needed) + //localStorage.setItem('access_token', access); + //localStorage.setItem('refresh_token', refresh); + saveTokens({ access, refresh }); + + if (requires_gen_otp){ + navigate('/2fa-setup-and-verification') + } + else if (requires_2fa){ + navigate('/2fa-verification'); + } + else{ + navigate('/dashboard'); + } + + + + } catch (err) { + setError(err.response?.data?.detail || 'Invalid credentials'); + } finally { + setLoading(false); + } + }; + return (
- + - + + - -

Login

-

Sign In to your account

+ +

Login

+

Sign In to your account

- + setUsername(e.target.value)} + required + /> @@ -42,16 +92,20 @@ const Login = () => { type="password" placeholder="Password" autoComplete="current-password" + value={password} + onChange={(e) => setPassword(e.target.value)} + required /> - - - - Login + {error &&

{error}

} + + + + {loading ? 'Logging in...' : 'Login'} - - + + Forgot password? @@ -59,7 +113,7 @@ const Login = () => {
- + {/*

Sign up

@@ -74,13 +128,13 @@ const Login = () => {
-
+
*/}
- ) -} + ); +}; export default Login diff --git a/src/views/pages/login/OTPVerify.js b/src/views/pages/login/OTPVerify.js new file mode 100644 index 000000000..ef2c48890 --- /dev/null +++ b/src/views/pages/login/OTPVerify.js @@ -0,0 +1,177 @@ +import React, { useEffect, useState, useRef } from "react"; +import {QRCodeCanvas} from "qrcode.react"; +import { + CCard, + CCardHeader, + CCardBody, + CCardFooter, + CButton, + CSpinner, + CForm, + CRow, + CCol, + CAlert, +} from "@coreui/react"; +import { api, saveTokens, clearTokens } from "../../../api/api"; + +const TwoFactorSetupAndVerification = () => { + const [qrData, setQrData] = useState(null); + const [loading, setLoading] = useState(true); + const [digits, setDigits] = useState(Array(6).fill("")); // OTP digits + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); // Success state + const inputRefs = useRef([]); // Refs for digit inputs + const verifyButtonRef = useRef(null); // Ref for the Verify button + + useEffect(() => { + // Fetch QR Code data from Django API + api + .post("auth/otp/generate/") + .then((response) => { + setQrData(response.data.otpauth_url); + }) + .catch((error) => { + console.error("Error fetching QR data:", error); + }) + .finally(() => { + setLoading(false); + }); + + // Focus on the first input field when component mounts + inputRefs.current[0]?.focus(); + }, []); + + const handleChange = (index, value) => { + if (/^\d?$/.test(value)) { + const newDigits = [...digits]; + newDigits[index] = value; + setDigits(newDigits); + + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } else if (value && index === 5) { + verifyButtonRef.current?.focus(); + } + } + }; + + const handleBackspace = (index) => { + if (index > 0 && digits[index] === "") { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handleFocus = (index) => { + inputRefs.current[index]?.select(); + }; + + const handle2FAVerification = async (e) => { + e.preventDefault(); + setError(null); + + const token = digits.join(""); + try { + await api.post("auth/otp/verify/", { token }); + setSuccess(true); // Set success state + clearTokens(); + + } catch (err) { + setError("Invalid 2FA code. Please try again."); + } + }; + return ( +
+ + + + +

Two-Factor Authentication

+
+ + {success ? ( + <> + + Two-Factor Authentication successfully verified! + + (window.location.href = "/#/login")} + > + Go to Login + + + ) : ( + <> + {loading ? ( + + ) : qrData ? ( + <> + +

+ Scan the QR Code with your authenticator app. +

+ + ) : ( +

Failed to load QR Code. Please try again.

+ )} +
+ +
+

+ Enter the 6-digit code from your app +

+
+ + {error && {error}} + +
+ {digits.map((digit, index) => ( + (inputRefs.current[index] = el)} + onInput={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => + e.key === "Backspace" && handleBackspace(index) + } + onFocus={() => handleFocus(index)} + className="form-control mx-1" + style={{ + width: "3rem", + textAlign: "center", + fontSize: "1.5rem", + }} + /> + ))} +
+ + + Verify + +
+ + )} +
+ + {!success && ( + window.location.reload()}> + Refresh QR Code + + )} + +
+
+
+
+ ); +}; + +export default TwoFactorSetupAndVerification; diff --git a/src/views/pages/login/TwoFactorVerification.js b/src/views/pages/login/TwoFactorVerification.js new file mode 100644 index 000000000..f3bfb7660 --- /dev/null +++ b/src/views/pages/login/TwoFactorVerification.js @@ -0,0 +1,137 @@ +import React, { useState, useEffect, useRef } from 'react' +import { + CButton, + CCard, + CCardBody, + CContainer, + CForm, + CFormInput, + CRow, + CCol, + CAlert, +} from '@coreui/react' +import { useNavigate } from 'react-router-dom' + +import { api, saveTokens } from '../../../api/api' + +import { cilShieldAlt } from '@coreui/icons' +import { CIcon } from '@coreui/icons-react' + +const TwoFactorVerification = () => { + const [digits, setDigits] = useState(Array(6).fill('')) // State for 6 digits + const [error, setError] = useState(null) + const navigate = useNavigate() + const inputRefs = useRef([]) // Refs for each input box + const verifyButtonRef = useRef(null); // Ref for the Verify button + + useEffect(() => { + inputRefs.current[0]?.focus() // Focus on the first input when the component loads + }, []) + + const handleChange = (index, value) => { + if (/^\d?$/.test(value)) { + // Only allow one digit + const newDigits = [...digits] + newDigits[index] = value + setDigits(newDigits) + + // Move to the next input or the Verify button if this is the last digit + if (value) { + if (index < 5) { + inputRefs.current[index + 1]?.focus() + } else { + verifyButtonRef.current?.focus() + } + } + } + } + + const handleBackspace = (index) => { + if (index > 0 && digits[index] === '') { + // Move focus back if backspace is pressed on an empty input + inputRefs.current[index - 1]?.focus() + } + } + + const handleFocus = (index) => { + inputRefs.current[index]?.select() // Select the content when focused + } + + const handle2FAVerification = async (e) => { + e.preventDefault() + setError(null) + + const token = digits.join('') // Combine digits into a single token + + try { + const response = await api.post(`auth/otp/validate/`, { token }) + + const { access, refresh } = response.data + + saveTokens({ access, refresh }) + + navigate('/#/dashboard') + } catch (err) { + setError('Invalid 2FA code. Please try again.') + } + } + + return ( +
+ + + + + + + + + + + + +
+

+ Enter the 6-digit code sent to your device +

+
+ + {error && {error}} + +
+ {digits.map((digit, index) => ( + (inputRefs.current[index] = el)} // Attach ref + onInput={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Backspace') handleBackspace(index) + }} + onFocus={() => handleFocus(index)} // Select content on focus + className="form-control mx-1" + style={{ width: '3rem', textAlign: 'center', fontSize: '1.5rem' }} + /> + ))} +
+ + + + + Verify + + + +
+
+
+
+
+
+
+ ) +} + +export default TwoFactorVerification diff --git a/src/views/pages/logout/Logout.js b/src/views/pages/logout/Logout.js new file mode 100644 index 000000000..04c0de2d5 --- /dev/null +++ b/src/views/pages/logout/Logout.js @@ -0,0 +1,23 @@ +import { useNavigate } from 'react-router-dom'; +import { clearTokens } from '../../../api/api'; +import { useEffect } from 'react'; +const Logout = () => { + const navigate = useNavigate(); + + const handleLogout = () => { + // Clear access and refresh tokens + clearTokens(); + + // Redirect to login page + navigate('/#/login'); + }; + + // Run the handleLogout function when this component is rendered + useEffect(() => { + handleLogout(); + }, []); + + return null; // No UI needed for this component +}; + +export default Logout; diff --git a/vite.config.mjs b/vite.config.mjs index ada856d4d..3194320cc 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -46,7 +46,8 @@ export default defineConfig(() => { extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.scss'], }, server: { - port: 3000, + port: 6006, + host: '0.0.0.0', proxy: { // https://vitejs.dev/config/server-options.html },