]> PHS Git Server - phs-admin.git/commitdiff
Adding vpn and docker elements as well as adding more git operations.
authorcharleswrayjr <charleswrayjr@gmail.com>
Fri, 5 Sep 2025 23:18:40 +0000 (18:18 -0500)
committercharleswrayjr <charleswrayjr@gmail.com>
Fri, 5 Sep 2025 23:18:40 +0000 (18:18 -0500)
16 files changed:
.idea/vcs.xml [new file with mode: 0644]
src/app/routes.js
src/app/services/Docker/DockerConfig.js [new file with mode: 0644]
src/app/services/Docker/DockerService.js [new file with mode: 0644]
src/app/services/Git/GitConfig.js [new file with mode: 0644]
src/app/services/Git/GitService.js [new file with mode: 0644]
src/app/services/VPN/VPNConfig.js [new file with mode: 0644]
src/app/services/VPN/VPNService.js [new file with mode: 0644]
src/app/services/index.js [new file with mode: 0644]
src/app/views/Docker/Docker.jsx [new file with mode: 0644]
src/app/views/Git.jsx [deleted file]
src/app/views/Git/Git.jsx [new file with mode: 0755]
src/app/views/Login.jsx [deleted file]
src/app/views/Login/Login.jsx [new file with mode: 0755]
src/app/views/VPN/VPN.jsx [new file with mode: 0644]
src/app/views/index.js [new file with mode: 0644]

diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644 (file)
index 0000000..94a25f7
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$" vcs="Git" />
+  </component>
+</project>
\ No newline at end of file
index 67634de28c20b04dcd71595ffbfd30883b33c95c..72b3019fe9e9e62d1e698af7dcbdd07778bfd2e9 100755 (executable)
@@ -1,8 +1,9 @@
 import { Route, Routes } from 'react-router-dom';
-import Login from './views/Login';
-import NotFound from './views/404/Error404Page';
+import { Login, NotFound, Dashboard, Git, VPN, Docker } from './views';
+/*import NotFound from './views/404/Error404Page';
 import Dashboard from './views/Dashboard/Dashboard';
-import Git from './views/Git';
+import Git from './views/Git/Git';*/
+
 
 export default function AppRoutes() {
   return (
@@ -10,6 +11,8 @@ export default function AppRoutes() {
       <Route path="/" element={<Dashboard />} />
       <Route path="/login" element={<Login />} />
       <Route path="/git" element={<Git />} />
+      <Route path="/vpn" element={<VPN />} />
+      <Route path="/docker" element={<Docker />} />
       <Route path="*" element={<NotFound />} />
     </Routes>
   );
diff --git a/src/app/services/Docker/DockerConfig.js b/src/app/services/Docker/DockerConfig.js
new file mode 100644 (file)
index 0000000..5c12e52
--- /dev/null
@@ -0,0 +1,11 @@
+const base = 'docker/';
+const DockerConfig = {
+  index: base,
+  start: `${base}start-container`,
+  stop: `${base}stop-container`,
+  images: `${base}images`,
+  containers: `${base}containers`,
+  down: `${base}compose-down`,
+};
+
+export default DockerConfig;
\ No newline at end of file
diff --git a/src/app/services/Docker/DockerService.js b/src/app/services/Docker/DockerService.js
new file mode 100644 (file)
index 0000000..d1c8d7d
--- /dev/null
@@ -0,0 +1,60 @@
+import axios from 'axios';
+import { base_url } from '../../../globals';
+import DockerConfig from './DockerConfig';
+
+class DockerService {
+
+  fetchContainers = () => {
+    return new Promise( async ( resolve, reject ) => {
+      try {
+        const response = await axios.get( `${ base_url }${ DockerConfig.containers }` );
+        resolve( response.data.containers ); // Resolve the promise with the data
+      } catch (error) {
+        reject( `Error fetching containers: ${ error.message }` ); // Reject the promise with an error message
+      }
+    } );
+  };
+
+  fetchImages = async () => {
+    return new Promise( async ( resolve, reject ) => {
+      try {
+        const response = await axios.get( `${ base_url }${ DockerConfig.images }` );
+        resolve( response.data.images ); // Resolve the promise with the data
+      } catch (error) {
+        console.error( error );
+        reject( `Error fetching images: ${ error.message }` ); // Reject the promise with an error message
+      }
+    } );
+  };
+
+  containerAction = async (action, id, fetchContainers) => {
+    return new Promise( async ( resolve, reject ) => {
+      try {
+        const response = await axios.post( `${ base_url }docker/${ action }-container`, {
+          containerId:id,
+        } );
+        fetchContainers();
+        resolve(response.data.message);
+      } catch (error) {
+        reject(`Error: ${ error.response?.data?.error || `Failed to ${ action } container` }`);
+      }
+    })
+  };
+
+  composeDown = async (composeFile, fetchContainers ) => {
+    return new Promise( async ( resolve, reject ) => {
+      try {
+        const response = await axios.post(`${base_url}${DockerConfig.down}`, {
+          composeFile,
+        });
+        resolve(response.data.message);
+        fetchContainers();
+      } catch (error) {
+        reject(`Error: ${error.response?.data?.error || 'Failed to stop compose services'}`);
+      }
+    });
+  };
+}
+
+const instance = new DockerService();
+export default instance;
\ No newline at end of file
diff --git a/src/app/services/Git/GitConfig.js b/src/app/services/Git/GitConfig.js
new file mode 100644 (file)
index 0000000..fb47d54
--- /dev/null
@@ -0,0 +1,9 @@
+let base = 'git/';
+const GitConfig = {
+  index: base,
+  createRepo: `${base}create-repo/`,
+  deleteRepo: `${base}`,
+  cloneRepo: `${base}clone-repo/`,
+};
+
+export default GitConfig;
\ No newline at end of file
diff --git a/src/app/services/Git/GitService.js b/src/app/services/Git/GitService.js
new file mode 100644 (file)
index 0000000..a381d71
--- /dev/null
@@ -0,0 +1,72 @@
+import axios from 'axios';
+import { base_url } from '../../../globals';
+import GitConfig from './GitConfig';
+
+class GitService {
+
+  fetchRepos = async ( token ) => {
+    return new Promise( async ( resolve, reject ) => {
+      try {
+        const response = await axios.get(`${base_url}${GitConfig.index}`, { headers: { Authorization: `Bearer ${token}` } });
+        resolve(response.data.repos);
+      } catch (error) {
+        reject(error.response?.data?.message || error.message);
+      }
+    })
+  };
+
+  /**
+   *
+   * @param {Object} data
+   * @param {Function} fetchRepos
+   * @returns {Promise<string>}
+   */
+  createRepo = (data, fetchRepos) => {
+    return new Promise( async ( resolve, reject ) => {
+      const { repoName, repoType, repoUser } = data;
+      try {
+        const response = await axios.post(`${base_url}${GitConfig.createRepo}`, {
+          name: repoName,
+          type: repoType,
+          user: repoType === 'private' ? repoUser : undefined,
+        });
+        resolve(response.data.message);
+        fetchRepos();
+      } catch (error) {
+        reject(`Error: ${error.response?.data?.error || 'Failed to create repository'}`);
+      }
+    });
+  };
+
+  deleteRepo = (name, fetchRepos) => {
+    return new Promise( async ( resolve ) => {
+      try {
+        const response = await axios.delete(`${base_url}${GitConfig.deleteRepo}/${name}`);
+        resolve(response.data.message);
+        fetchRepos();
+      } catch (error) {
+        resolve(`Error: ${error.response?.data?.error || 'Failed to delete repository'}`);
+      }
+    });
+  };
+
+  cloneRepo = (data) => {
+    return new Promise( async ( resolve, reject ) => {
+      const {deployRepoName, deployPath, deployUser } = data;
+        try {
+          const response = await axios.post(`${base_url}${GitConfig.cloneRepo}`, {
+            repoName: deployRepoName,
+            deployPath,
+            user: deployUser,
+          });
+          resolve(response.data.message);
+        } catch (error) {
+          reject(`Error: ${error.response?.data?.error || 'Failed to clone repository'}`);
+        }
+    });
+  };
+
+}
+
+const instance = new GitService();
+export default instance;
\ No newline at end of file
diff --git a/src/app/services/VPN/VPNConfig.js b/src/app/services/VPN/VPNConfig.js
new file mode 100644 (file)
index 0000000..39e1127
--- /dev/null
@@ -0,0 +1,8 @@
+const base = 'vpn/';
+const VPNConfig = {
+  create: `${base}create-client/`,
+  delete: `${base}revoke-client/`,
+  index: `${base}clients/`
+};
+
+export default VPNConfig;
\ No newline at end of file
diff --git a/src/app/services/VPN/VPNService.js b/src/app/services/VPN/VPNService.js
new file mode 100644 (file)
index 0000000..f60f577
--- /dev/null
@@ -0,0 +1,60 @@
+import axios from 'axios';
+import { base_url } from '../../../globals';
+import VPNConfig from './VPNConfig';
+
+class VPNService {
+  fetchClients = () => {
+    return new Promise( async ( resolve ) => {
+      try {
+        const response = await axios.get(`${base_url}${VPNConfig.index}`);
+        resolve(response.data.clients);
+      } catch (error) {
+        resolve(`Error: ${error.response?.data?.error || 'Failed to fetch clients'}`);
+      }
+    });
+  };
+
+  createClient = ( data, fetchClients ) => {
+    return new Promise( async ( resolve ) => {
+      const { clientName, useStaticIp, staticIp } = data;
+      try {
+        const response = await axios.post(`${base_url}${VPNConfig.create}`, {
+          clientName,
+          staticIp: useStaticIp ? staticIp : undefined,
+        });
+        fetchClients();
+        if (response.data.ovpn) {
+          const element = document.createElement('a');
+          const file = new Blob([response.data.ovpn], { type: 'text/plain' });
+          element.href = URL.createObjectURL(file);
+          element.download = `${clientName}.ovpn`;
+          document.body.appendChild(element);
+          element.click();
+          document.body.removeChild(element);
+        }
+        resolve(response.data.message);
+      } catch (error) {
+        resolve(`Error: ${error.response?.data?.error || 'Failed to create client'}`);
+      }
+    })
+  };
+
+  revokeClient = ( data, fetchClients ) => {
+    return new Promise( async ( resolve ) => {
+      const { revokeClientName } = data;
+      try {
+        const response = await axios.post(`${base_url}${VPNConfig.delete}`, {
+          clientName: revokeClientName,
+        });
+        fetchClients();
+        resolve(response.data.message);
+      } catch (error) {
+        resolve(`Error: ${error.response?.data?.error || 'Failed to revoke client'}`);
+      }
+    });
+  };
+
+}
+
+const instance = new VPNService();
+export default instance;
\ No newline at end of file
diff --git a/src/app/services/index.js b/src/app/services/index.js
new file mode 100644 (file)
index 0000000..9c81015
--- /dev/null
@@ -0,0 +1,5 @@
+import DockerService from './Docker/DockerService';
+import GitService from './Git/GitService';
+import VPNService from './VPN/VPNService';
+
+export { DockerService, GitService, VPNService };
\ No newline at end of file
diff --git a/src/app/views/Docker/Docker.jsx b/src/app/views/Docker/Docker.jsx
new file mode 100644 (file)
index 0000000..8836601
--- /dev/null
@@ -0,0 +1,108 @@
+import React, { useState, useEffect } from 'react';
+import { Container, Typography, TextField, Button } from '@mui/material';
+import { MaterialReactTable } from 'material-react-table';
+import { DockerService } from '../../services';
+
+
+const Docker = () => {
+  const [containers, setContainers] = useState([]);
+  const [images, setImages] = useState([]);
+  const [composeFile, setComposeFile] = useState('');
+  const [message, setMessage] = useState('');
+
+  const fetchContainers = async () => {
+    try {
+      const containersData = await DockerService.fetchContainers(); // Assuming you have a service to fetch containers
+      setContainers(containersData);
+    } catch (error) {
+      setMessage(error);
+    }
+  };
+
+  const fetchImages = async () => {
+    try {
+      const imagesData = await DockerService.fetchImages(); // Assuming you have a service to fetch images
+      setImages(imagesData);
+    } catch (error) {
+      setMessage(error);
+    }
+  };
+
+  useEffect( () => {
+    fetchImages().catch( e => setMessage( e ) );
+    fetchContainers().catch( e => setMessage( e ) );
+  }, [] );
+
+  const handleContainerAction = async (action, containerId) => {
+    await DockerService.containerAction(action, containerId);
+    await fetchContainers();
+  };
+
+  const handleComposeSubmit = async (event) => {
+    event.preventDefault();
+    setMessage( await DockerService.composeDown( composeFile ) );
+  };
+
+  return(
+    <Container>
+      <Typography variant="h5" className="mt-6">Docker Containers</Typography>
+      <MaterialReactTable
+        columns={[
+          { accessorKey: 'id', header: 'ID' },
+          { accessorKey: 'name', header: 'Name' },
+          { accessorKey: 'state', header: 'State' },
+          { accessorKey: 'status', header: 'Status' },
+          {
+            header: 'Actions',
+            Cell: ({ row }) => (
+              <>
+                <Button
+                  variant="contained"
+                  color="primary"
+                  onClick={() => handleContainerAction('start', row.original.id)}
+                  disabled={row.original.state === 'running'}
+                >
+                  Start
+                </Button>
+                <Button
+                  variant="contained"
+                  color="secondary"
+                  onClick={() => handleContainerAction('stop', row.original.id)}
+                  disabled={row.original.state !== 'running'}
+                >
+                  Stop
+                </Button>
+              </>
+            ),
+          },
+        ]}
+        data={containers}
+      />
+
+      <Typography variant="h5" className="mt-6">Docker Images</Typography>
+      <MaterialReactTable
+        columns={[
+          { accessorKey: 'id', header: 'ID' },
+          { accessorKey: 'tags', header: 'Tags', Cell: ({ cell }) => cell.getValue().join(', ') },
+          { accessorKey: 'created', header: 'Created' },
+        ]}
+        data={images}
+      />
+
+      <Typography variant="h5" className="mt-6">Stop Docker Compose Services</Typography>
+      <form onSubmit={handleComposeSubmit} className="space-y-4">
+        <TextField
+          label="Compose File Path"
+          value={composeFile}
+          onChange={(e) => setComposeFile(e.target.value)}
+          fullWidth
+        />
+        <Button type="submit" variant="contained" color="secondary">Stop Compose Services</Button>
+      </form>
+
+      {message && <Typography color={message.startsWith('Error') ? 'error' : 'success'}>{message}</Typography>}
+    </Container>
+  )
+}
+
+export default Docker;
\ No newline at end of file
diff --git a/src/app/views/Git.jsx b/src/app/views/Git.jsx
deleted file mode 100755 (executable)
index 5d7f69c..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useState } from 'react';
-import axios from 'axios';
-import { base_url } from '../../globals';
-
-const Git = () => {
-  const [repoName, setRepoName] = useState('');
-  const [repoType, setRepoType] = useState('global');
-  const [user, setUser] = useState('');
-  const [message, setMessage] = useState('');
-
-  const handleSubmit = async (e) => {
-    e.preventDefault();
-    if (!repoName.match(/^[a-zA-Z0-9_-]+$/)) {
-      setMessage('Error: Repository name must contain only letters, numbers, hyphens, or underscores.');
-      return;
-    }
-    if (repoType === 'private' && !user) {
-      setMessage('Error: Username required for private repository.');
-      return;
-    }
-
-    try {
-      const response = await axios.post(`${base_url}git/create-repo`, {
-        name: repoName,
-        type: repoType,
-        user: repoType === 'private' ? user : undefined,
-      });
-      setMessage(response.data.message);
-    } catch (error) {
-      setMessage(`Error: ${error.response?.data?.error || 'Failed to create repository'}`);
-    }
-  };
-
-  return (
-    <div className="min-h-screen bg-gray-100 flex items-center justify-center">
-      <div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md">
-        <h1 className="text-2xl font-bold mb-6 text-center">Git Repository Manager</h1>
-        <form onSubmit={handleSubmit}>
-          <div className="mb-4">
-            <label className="block text-gray-700 mb-2" htmlFor="repoName">Repository Name</label>
-            <input
-              id="repoName"
-              type="text"
-              value={repoName}
-              onChange={(e) => setRepoName(e.target.value)}
-              className="w-full p-2 border rounded"
-              placeholder="e.g., my-project"
-              required
-            />
-          </div>
-          <div className="mb-4">
-            <label className="block text-gray-700 mb-2">Repository Type</label>
-            <select
-              value={repoType}
-              onChange={(e) => setRepoType(e.target.value)}
-              className="w-full p-2 border rounded"
-            >
-              <option value="global">Global (Group-Shared)</option>
-              <option value="private">Private (User-Specific)</option>
-            </select>
-          </div>
-          {repoType === 'private' && (
-            <div className="mb-4">
-              <label className="block text-gray-700 mb-2" htmlFor="user">Username</label>
-              <input
-                id="user"
-                type="text"
-                value={user}
-                onChange={(e) => setUser(e.target.value)}
-                className="w-full p-2 border rounded"
-                placeholder="e.g., gituser1"
-                required
-              />
-            </div>
-          )}
-          <button
-            type="submit"
-            className="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600"
-          >
-            Create Repository
-          </button>
-        </form>
-        {message && (
-          <p className={`mt-4 text-center ${message.startsWith('Error') ? 'text-red-500' : 'text-green-500'}`}>
-            {message}
-          </p>
-        )}
-      </div>
-    </div>
-  );
-}
-
-export default Git;
\ No newline at end of file
diff --git a/src/app/views/Git/Git.jsx b/src/app/views/Git/Git.jsx
new file mode 100755 (executable)
index 0000000..8f15f2f
--- /dev/null
@@ -0,0 +1,125 @@
+import React, { useState, useEffect } from 'react';
+
+import { Container, Typography, TextField, Button, Select, MenuItem } from '@mui/material';
+import { MaterialReactTable } from 'material-react-table';
+import { GitService } from '../../services';
+
+const Git = () => {
+  const [repoName, setRepoName] = useState('');
+  const [repoType, setRepoType] = useState('global');
+  const [repoUser, setRepoUser] = useState('');
+  const [message, setMessage] = useState('');
+  const [repos, setRepos] = useState( [] );
+  const [deployRepoName, setDeployRepoName] = useState('');
+  const [deployPath, setDeployPath] = useState('');
+  const [deployUser, setDeployUser] = useState('');
+
+  const fetchRepos = async () => {
+    await GitService.fetchRepos().then( res => setRepos( res ) ).catch( err => setMessage( err ) );
+  };
+
+  useEffect(() => {
+    fetchRepos().catch( e => setMessage( e ) );
+  }, [] );
+
+  const handleRepoSubmit = async (e) => {
+    e.preventDefault();
+    setMessage( await GitService.createRepo({repoName, repoType, repoUser}, fetchRepos ) );
+  };
+
+  const handleDeleteRepo = async (name) => {
+      setMessage( await GitService.deleteRepo( name, fetchRepos ) );
+  };
+
+  const handleCloneSubmit = async (e) => {
+    e.preventDefault();
+    setMessage( await GitService.cloneRepo( { deployRepoName, deployPath, deployUser } ) );
+  };
+
+  return(
+    <Container>
+      <Typography variant="h5">Create Git Repository</Typography>
+      <form onSubmit={handleRepoSubmit} className="space-y-4">
+        <TextField
+          label="Repository Name"
+          value={repoName}
+          onChange={(e) => setRepoName(e.target.value)}
+          fullWidth
+        />
+        <Select
+          value={repoType}
+          onChange={(e) => setRepoType(e.target.value)}
+          fullWidth
+        >
+          <MenuItem value="global">Global (Group-Shared)</MenuItem>
+          <MenuItem value="private">Private (User-Specific)</MenuItem>
+        </Select>
+        {repoType === 'private' && (
+          <TextField
+            label="Username"
+            value={repoUser}
+            onChange={(e) => setRepoUser(e.target.value)}
+            fullWidth
+          />
+        )}
+        <Button type="submit" variant="contained" color="primary">Create Repository</Button>
+      </form>
+
+      <Typography variant="h5" className="mt-6">Clone Repository Locally</Typography>
+      <form onSubmit={handleCloneSubmit} className="space-y-4">
+        <TextField
+          label="Repository Name"
+          value={deployRepoName}
+          onChange={(e) => setDeployRepoName(e.target.value)}
+          fullWidth
+        />
+        <TextField
+          label="Deployment Path"
+          value={deployPath}
+          onChange={(e) => setDeployPath(e.target.value)}
+          fullWidth
+        />
+        <TextField
+          label="Username"
+          value={deployUser}
+          onChange={(e) => setDeployUser(e.target.value)}
+          fullWidth
+        />
+        <Button type="submit" variant="contained" color="primary">Clone Repository</Button>
+      </form>
+
+      <Typography variant="h5" className="mt-6">Repositories</Typography>
+      <MaterialReactTable
+        columns={[
+          { accessorKey: 'name', header: 'Name' },
+          { accessorKey: 'cloneUrl', header: 'Clone URL' },
+          {
+            header: 'Actions',
+            Cell: ({ row }) => (
+              <Button
+                variant="contained"
+                color="secondary"
+                onClick={() => handleDeleteRepo(row.original.name)}
+              >
+                Delete
+              </Button>
+            ),
+          },
+        ]}
+        data={repos}
+        enableRowActions
+        renderRowActions={({ row }) => (
+          <Button
+            onClick={() => navigator.clipboard.writeText(row.original.cloneUrl)}
+          >
+            Copy Clone URL
+          </Button>
+        )}
+      />
+
+      {message && <Typography color={message.startsWith('Error') ? 'error' : 'success'}>{message}</Typography>}
+    </Container>
+  );
+};
+
+export default Git;
\ No newline at end of file
diff --git a/src/app/views/Login.jsx b/src/app/views/Login.jsx
deleted file mode 100755 (executable)
index 8dd74f2..0000000
+++ /dev/null
@@ -1,242 +0,0 @@
-import { useState } from 'react';
-import { yupResolver } from '@hookform/resolvers/yup';
-import { Controller, useForm } from 'react-hook-form';
-import {
-  Button,
-  Checkbox,
-  FormControl,
-  FormControlLabel,
-  FormHelperText,
-  TextField,
-  Typography,
-  Box,
-  Paper,
-  InputAdornment, IconButton
-} from '@mui/material';
-import * as yup from 'yup';
-import _ from '../../@lodash';
-// import jwtService from '../../auth/services/jwtService';
-import { Visibility, VisibilityOff } from '@mui/icons-material';
-import { useNavigate } from 'react-router-dom';
-
-/**
- * Form Validation Schema
- */
-const schema = yup.object().shape( {
-  email:yup.string().email( 'You must enter a valid email' ).required( 'You must enter a email' ),
-  password:yup
-    .string()
-    .required( 'Please enter your password.' )
-    .min( 4, 'Password is too short - must be at least 4 chars.' ),
-} );
-
-const defaultValues = {
-  email:'',
-  password:'',
-  remember:true,
-};
-
-function SignInPage() {
-  const { control, formState, handleSubmit, setError } = useForm( {
-    mode:'onChange',
-    defaultValues,
-    resolver:yupResolver( schema ),
-  } );
-  const [showPassword, setShowPassword] = useState( false );
-  const [signInError, setSignInError] = useState(null);
-  const navigate = useNavigate();
-  const { isValid, dirtyFields, errors } = formState;
-
-  function onSubmit( { email, password } ) {
-    setSignInError(null);
-    /*jwtService
-      .signInWithEmailAndPassword( email, password )
-      .then( data => {
-        if(data.requires_2fa) {
-          navigate('/sign-in-verify', { state: { data: data } });
-        }
-      } )
-      .catch( (error) => {
-        const message = error?.response?.data?.message;
-        console.log(`Sign-in error: ${error}, sub error: ${message}`);
-        setSignInError(message);
-      } );*/
-  }
-
-  return (
-    <div
-      className="flex flex-col sm:flex-row items-center md:items-start sm:justify-center md:justify-start flex-1 min-w-0">
-      <Paper
-        className="h-full sm:h-auto md:flex md:items-center md:justify-end w-full sm:w-auto md:h-full md:w-1/2 py-8 px-16 sm:p-48 md:p-64 sm:rounded-2xl md:rounded-none sm:shadow md:shadow-none ltr:border-r-1 rtl:border-l-1">
-        <div className="w-full max-w-320 sm:w-320 mx-auto sm:mx-0">
-
-          <Typography fontSize={'2rem'} className="mt-32 text-4xl font-extrabold tracking-tight leading-tight">
-            Sign in
-          </Typography>
-
-          {signInError &&
-            <FormHelperText id="validation-helper-text" className="mt-24 mb-24 text-lg" error>
-              {signInError}
-            </FormHelperText>
-          }
-
-          <form
-            name="loginForm"
-            noValidate
-            className="flex flex-col justify-center w-full mt-32"
-            onSubmit={ handleSubmit( onSubmit ) }
-          >
-            <Controller
-              name="employee_number"
-              control={ control }
-              render={ ( { field } ) => (
-                <TextField
-                  { ...field }
-                  className="mb-24"
-                  label="Employee Number"
-                  autoFocus
-                  type="text"
-                  error={ !!errors.employee_number }
-                  helperText={ errors?.employee_number?.message }
-                  variant="outlined"
-                  required
-                  fullWidth
-                />
-              ) }
-            />
-
-            <Controller
-              name="password"
-              control={ control }
-              render={ ( { field } ) => (
-                <TextField
-                  { ...field }
-                  className="mb-6"
-                  label="Password"
-                  type={ showPassword ? 'text' : 'password' }
-                  error={ !!errors.password }
-                  inputProps={{
-                    'aria-describedby': errors.password ? 'password-helper-text' : undefined,
-                  }}
-                  InputProps={{
-                    endAdornment:
-                      <InputAdornment position='end' >
-                        <IconButton aria-label='toggle password visibility' onClick={ () => setShowPassword( prevState => !prevState ) }>
-                          { showPassword ? <VisibilityOff/> : <Visibility/> }
-                        </IconButton>
-                      </InputAdornment>
-                  }}
-                  variant="outlined"
-                  required
-                  fullWidth
-                />
-              ) }
-            />
-            {errors?.password && (
-              <FormHelperText id="password-helper-text" className="mb-24" error>
-                {errors.password.message}
-              </FormHelperText>
-            )}
-
-            <div className="flex flex-col sm:flex-row items-center justify-center sm:justify-between">
-              <Controller
-                name="remember"
-                control={ control }
-                render={ ( { field } ) => (
-                  <FormControl>
-                    <FormControlLabel
-                      label="Remember me"
-                      control={ <Checkbox size="small" { ...field } /> }
-                    />
-                  </FormControl>
-                ) }
-              />
-            </div>
-
-            <Button
-              variant="contained"
-              color="secondary"
-              className=" w-full mt-16"
-              aria-label="Sign in"
-              disabled={ _.isEmpty( dirtyFields ) || !isValid }
-              type="submit"
-              size="large"
-            >Sign in</Button>
-
-            <Button
-              variant="outlined"
-              color="primary"
-              className=" w-full mt-16"
-              aria-label="Forgot Password"
-              size="large"
-              onClick={() => {
-                navigate('/forgot-password');
-              }}
-            >Forgot Password</Button>
-
-          </form>
-        </div>
-      </Paper>
-
-      <Box
-        className="relative hidden md:flex flex-auto items-center justify-center h-full p-64 lg:px-112 overflow-hidden"
-        sx={ { backgroundColor:'primary.main' } }
-      >
-        <svg
-          className="absolute inset-0 pointer-events-none"
-          viewBox="0 0 960 540"
-          width="100%"
-          height="100%"
-          preserveAspectRatio="xMidYMax slice"
-          xmlns="http://www.w3.org/2000/svg"
-        >
-          <Box
-            component="g"
-            sx={ { color:'primary.light' } }
-            className="opacity-20"
-            fill="none"
-            stroke="currentColor"
-            strokeWidth="100"
-          >
-            <circle r="234" cx="196" cy="23"/>
-            <circle r="234" cx="790" cy="491"/>
-          </Box>
-        </svg>
-        <Box
-          component="svg"
-          className="absolute -top-64 -right-64 opacity-20"
-          sx={ { color:'primary.light' } }
-          viewBox="0 0 220 192"
-          width="220px"
-          height="192px"
-          fill="none"
-        >
-          <defs>
-            <pattern
-              id="837c3e70-6c3a-44e6-8854-cc48c737b659"
-              x="0"
-              y="0"
-              width="20"
-              height="20"
-              patternUnits="userSpaceOnUse"
-            >
-              <rect x="0" y="0" width="4" height="4" fill="currentColor"/>
-            </pattern>
-          </defs>
-          <rect width="220" height="192" fill="url(#837c3e70-6c3a-44e6-8854-cc48c737b659)"/>
-        </Box>
-
-        <div className="z-10 relative w-full max-w-2xl">
-          <div className="text-7xl font-bold leading-none text-grey-100">
-            <div>Please Login</div>
-          </div>
-          <div className="mt-24 text-lg tracking-tight leading-6 text-grey-400">
-            Enter your company employee number and password to log in.
-          </div>
-        </div>
-      </Box>
-    </div>
-  );
-}
-
-export default SignInPage;
diff --git a/src/app/views/Login/Login.jsx b/src/app/views/Login/Login.jsx
new file mode 100755 (executable)
index 0000000..b3974fc
--- /dev/null
@@ -0,0 +1,242 @@
+import { useState } from 'react';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { Controller, useForm } from 'react-hook-form';
+import {
+  Button,
+  Checkbox,
+  FormControl,
+  FormControlLabel,
+  FormHelperText,
+  TextField,
+  Typography,
+  Box,
+  Paper,
+  InputAdornment, IconButton
+} from '@mui/material';
+import * as yup from 'yup';
+import _ from '@lodash';
+// import jwtService from '../../auth/services/jwtService';
+import { Visibility, VisibilityOff } from '@mui/icons-material';
+import { useNavigate } from 'react-router-dom';
+
+/**
+ * Form Validation Schema
+ */
+const schema = yup.object().shape( {
+  email:yup.string().email( 'You must enter a valid email' ).required( 'You must enter a email' ),
+  password:yup
+    .string()
+    .required( 'Please enter your password.' )
+    .min( 4, 'Password is too short - must be at least 4 chars.' ),
+} );
+
+const defaultValues = {
+  email:'',
+  password:'',
+  remember:true,
+};
+
+function SignInPage() {
+  const { control, formState, handleSubmit, setError } = useForm( {
+    mode:'onChange',
+    defaultValues,
+    resolver:yupResolver( schema ),
+  } );
+  const [showPassword, setShowPassword] = useState( false );
+  const [signInError, setSignInError] = useState(null);
+  const navigate = useNavigate();
+  const { isValid, dirtyFields, errors } = formState;
+
+  function onSubmit( { email, password } ) {
+    setSignInError(null);
+    /*jwtService
+      .signInWithEmailAndPassword( email, password )
+      .then( data => {
+        if(data.requires_2fa) {
+          navigate('/sign-in-verify', { state: { data: data } });
+        }
+      } )
+      .catch( (error) => {
+        const message = error?.response?.data?.message;
+        console.log(`Sign-in error: ${error}, sub error: ${message}`);
+        setSignInError(message);
+      } );*/
+  }
+
+  return (
+    <div
+      className="flex flex-col sm:flex-row items-center md:items-start sm:justify-center md:justify-start flex-1 min-w-0">
+      <Paper
+        className="h-full sm:h-auto md:flex md:items-center md:justify-end w-full sm:w-auto md:h-full md:w-1/2 py-8 px-16 sm:p-48 md:p-64 sm:rounded-2xl md:rounded-none sm:shadow md:shadow-none ltr:border-r-1 rtl:border-l-1">
+        <div className="w-full max-w-320 sm:w-320 mx-auto sm:mx-0">
+
+          <Typography fontSize={'2rem'} className="mt-32 text-4xl font-extrabold tracking-tight leading-tight">
+            Sign in
+          </Typography>
+
+          {signInError &&
+            <FormHelperText id="validation-helper-text" className="mt-24 mb-24 text-lg" error>
+              {signInError}
+            </FormHelperText>
+          }
+
+          <form
+            name="loginForm"
+            noValidate
+            className="flex flex-col justify-center w-full mt-32"
+            onSubmit={ handleSubmit( onSubmit ) }
+          >
+            <Controller
+              name="employee_number"
+              control={ control }
+              render={ ( { field } ) => (
+                <TextField
+                  { ...field }
+                  className="mb-24"
+                  label="Employee Number"
+                  autoFocus
+                  type="text"
+                  error={ !!errors.employee_number }
+                  helperText={ errors?.employee_number?.message }
+                  variant="outlined"
+                  required
+                  fullWidth
+                />
+              ) }
+            />
+
+            <Controller
+              name="password"
+              control={ control }
+              render={ ( { field } ) => (
+                <TextField
+                  { ...field }
+                  className="mb-6"
+                  label="Password"
+                  type={ showPassword ? 'text' : 'password' }
+                  error={ !!errors.password }
+                  inputProps={{
+                    'aria-describedby': errors.password ? 'password-helper-text' : undefined,
+                  }}
+                  InputProps={{
+                    endAdornment:
+                      <InputAdornment position='end' >
+                        <IconButton aria-label='toggle password visibility' onClick={ () => setShowPassword( prevState => !prevState ) }>
+                          { showPassword ? <VisibilityOff/> : <Visibility/> }
+                        </IconButton>
+                      </InputAdornment>
+                  }}
+                  variant="outlined"
+                  required
+                  fullWidth
+                />
+              ) }
+            />
+            {errors?.password && (
+              <FormHelperText id="password-helper-text" className="mb-24" error>
+                {errors.password.message}
+              </FormHelperText>
+            )}
+
+            <div className="flex flex-col sm:flex-row items-center justify-center sm:justify-between">
+              <Controller
+                name="remember"
+                control={ control }
+                render={ ( { field } ) => (
+                  <FormControl>
+                    <FormControlLabel
+                      label="Remember me"
+                      control={ <Checkbox size="small" { ...field } /> }
+                    />
+                  </FormControl>
+                ) }
+              />
+            </div>
+
+            <Button
+              variant="contained"
+              color="secondary"
+              className=" w-full mt-16"
+              aria-label="Sign in"
+              disabled={ _.isEmpty( dirtyFields ) || !isValid }
+              type="submit"
+              size="large"
+            >Sign in</Button>
+
+            <Button
+              variant="outlined"
+              color="primary"
+              className=" w-full mt-16"
+              aria-label="Forgot Password"
+              size="large"
+              onClick={() => {
+                navigate('/forgot-password');
+              }}
+            >Forgot Password</Button>
+
+          </form>
+        </div>
+      </Paper>
+
+      <Box
+        className="relative hidden md:flex flex-auto items-center justify-center h-full p-64 lg:px-112 overflow-hidden"
+        sx={ { backgroundColor:'primary.main' } }
+      >
+        <svg
+          className="absolute inset-0 pointer-events-none"
+          viewBox="0 0 960 540"
+          width="100%"
+          height="100%"
+          preserveAspectRatio="xMidYMax slice"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <Box
+            component="g"
+            sx={ { color:'primary.light' } }
+            className="opacity-20"
+            fill="none"
+            stroke="currentColor"
+            strokeWidth="100"
+          >
+            <circle r="234" cx="196" cy="23"/>
+            <circle r="234" cx="790" cy="491"/>
+          </Box>
+        </svg>
+        <Box
+          component="svg"
+          className="absolute -top-64 -right-64 opacity-20"
+          sx={ { color:'primary.light' } }
+          viewBox="0 0 220 192"
+          width="220px"
+          height="192px"
+          fill="none"
+        >
+          <defs>
+            <pattern
+              id="837c3e70-6c3a-44e6-8854-cc48c737b659"
+              x="0"
+              y="0"
+              width="20"
+              height="20"
+              patternUnits="userSpaceOnUse"
+            >
+              <rect x="0" y="0" width="4" height="4" fill="currentColor"/>
+            </pattern>
+          </defs>
+          <rect width="220" height="192" fill="url(#837c3e70-6c3a-44e6-8854-cc48c737b659)"/>
+        </Box>
+
+        <div className="z-10 relative w-full max-w-2xl">
+          <div className="text-7xl font-bold leading-none text-grey-100">
+            <div>Please Login</div>
+          </div>
+          <div className="mt-24 text-lg tracking-tight leading-6 text-grey-400">
+            Enter your company employee number and password to log in.
+          </div>
+        </div>
+      </Box>
+    </div>
+  );
+}
+
+export default SignInPage;
diff --git a/src/app/views/VPN/VPN.jsx b/src/app/views/VPN/VPN.jsx
new file mode 100644 (file)
index 0000000..e923618
--- /dev/null
@@ -0,0 +1,131 @@
+import React, { useState, useEffect } from 'react';
+import { Container, Typography, TextField, Button, Select, MenuItem } from '@mui/material';
+import { MaterialReactTable } from 'material-react-table';
+/*import axios from 'axios';*/
+import 'tailwindcss/tailwind.css';
+import { VPNService } from '../../services';
+
+const VPN = () => {
+  /*const [token, setToken] = useState('');
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');*/
+  const [clientName, setClientName] = useState('');
+  const [staticIp, setStaticIp] = useState('');
+  const [useStaticIp, setUseStaticIp] = useState(false);
+  const [revokeClientName, setRevokeClientName] = useState('');
+  const [clients, setClients] = useState([]);
+  const [message, setMessage] = useState('');
+
+  const fetchClients = async () => {
+    await VPNService.fetchClients().then( res => setClients( res ) ).catch( err => setMessage( err ) );
+  };
+
+  useEffect( () => {
+    fetchClients().catch( e => setMessage( e ) );
+  }, [] );
+
+  /*const handleLogin = async (e) => {
+    e.preventDefault();
+    try {
+      const response = await axios.post('http://localhost:3000/login', { username, password });
+      setToken(response.data.token);
+      setMessage('Logged in successfully');
+    } catch (error) {
+      setMessage(`Error: ${error.response?.data?.error || 'Login failed'}`);
+    }
+  };*/
+
+  const handleCreateClientSubmit = async (e) => {
+    e.preventDefault();
+    setMessage( await VPNService.createClient({ clientName, useStaticIp, staticIp }, fetchClients ) );
+  };
+
+  const handleRevokeClientSubmit = async (e) => {
+    e.preventDefault();
+    setMessage( await VPNService.revokeClient(revokeClientName) );
+  };
+
+  return (
+    <Container>
+      <Typography variant="h5" className="mt-6">Create OpenVPN Client</Typography>
+      <form onSubmit={handleCreateClientSubmit} className="space-y-4">
+        <TextField
+          label="Client Name"
+          value={clientName}
+          onChange={(e) => setClientName(e.target.value)}
+          fullWidth
+        />
+        <Select
+          value={useStaticIp}
+          onChange={(e) => setUseStaticIp(e.target.value)}
+          fullWidth
+        >
+          <MenuItem value={false}>Dynamic IP</MenuItem>
+          <MenuItem value={true}>Static IP</MenuItem>
+        </Select>
+        {useStaticIp && (
+          <TextField
+            label="Static IP"
+            value={staticIp}
+            onChange={(e) => setStaticIp(e.target.value)}
+            fullWidth
+            placeholder="e.g., 10.8.0.x"
+          />
+        )}
+        <Button type="submit" variant="contained" color="primary">Create Client</Button>
+      </form>
+
+      <Typography variant="h5" className="mt-6">Revoke OpenVPN Client</Typography>
+      <form onSubmit={handleRevokeClientSubmit} className="space-y-4">
+        <TextField
+          label="Client Name"
+          value={revokeClientName}
+          onChange={(e) => setRevokeClientName(e.target.value)}
+          fullWidth
+        />
+        <Button type="submit" variant="contained" color="secondary">Revoke Client</Button>
+      </form>
+
+      <Typography variant="h5" className="mt-6">Connected OpenVPN Clients</Typography>
+      <MaterialReactTable
+        columns={[
+          { accessorKey: 'name', header: 'Name' },
+          { accessorKey: 'ip', header: 'IP' },
+          { accessorKey: 'connectedSince', header: 'Connected Since' },
+        ]}
+        data={clients}
+      />
+
+      {message && <Typography color={message.startsWith('Error') ? 'error' : 'success'}>{message}</Typography>}
+    </Container>
+  );
+
+  /*return (
+    <Container>
+      <Typography variant="h4" gutterBottom>PHS Admin Dashboard</Typography>
+      {!token ? (
+        <form onSubmit={handleLogin} className="space-y-4">
+          <TextField
+            label="Username"
+            value={username}
+            onChange={(e) => setUsername(e.target.value)}
+            fullWidth
+          />
+          <TextField
+            label="Password"
+            type="password"
+            value={password}
+            onChange={(e) => setPassword(e.target.value)}
+            fullWidth
+          />
+          <Button type="submit" variant="contained" color="primary">Login</Button>
+        </form>
+      ) : (
+        <>
+        </>
+      )}
+    </Container>
+  );*/
+};
+
+export default VPN;
\ No newline at end of file
diff --git a/src/app/views/index.js b/src/app/views/index.js
new file mode 100644 (file)
index 0000000..8a3426a
--- /dev/null
@@ -0,0 +1,8 @@
+import NotFound from './404/Error404Page';
+import Dashboard from './Dashboard/Dashboard';
+import Login from './Login/Login';
+import Docker from './Docker/Docker';
+import VPN from './VPN/VPN';
+import Git from './Git/Git';
+
+export {NotFound, Dashboard, Login, Docker, VPN, Git };
\ No newline at end of file