From: charleswrayjr Date: Sat, 13 Sep 2025 06:31:39 +0000 (-0500) Subject: Adding authentication, media, and messaging. X-Git-Url: https://git.phasecustomsoft.com/static/git-logo.png?a=commitdiff_plain;h=7bc91a32325440c45fa0894f508285f9a49e7749;p=phs-admin.git Adding authentication, media, and messaging. --- diff --git a/src/App.js b/src/App.js index 16de745..7a5a3db 100755 --- a/src/App.js +++ b/src/App.js @@ -1,14 +1,28 @@ import './styles/App.css'; import AppRoutes from './app/routes'; +import React from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { SnackbarProvider } from 'notistack'; +import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'; -function App() { +const theme = createTheme({ + palette: { + primary: { main: '#1976d2' }, + secondary: { main: '#dc004e' }, + }, +}); + +const App = () => { return ( -
-
-
- { AppRoutes() } -
+ + + + + + + + ); -} +}; export default App; diff --git a/src/app/components/AppHeader.js b/src/app/components/AppHeader.js new file mode 100644 index 0000000..365dd84 --- /dev/null +++ b/src/app/components/AppHeader.js @@ -0,0 +1,57 @@ +/** + * @file Reusable header component with navigation + */ + +import React, { useContext } from 'react'; +import { AppBar, Toolbar, Typography, Button, Box } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { AuthContext } from './AuthContext'; + +const AppHeader = () => { + const navigate = useNavigate(); + const { user, logout } = useContext(AuthContext); + + const navItems = [ + { label: 'Dashboard', path: '/', roles: ['User', 'Admin'] }, + { label: 'Messages', path: '/messages', roles: ['User', 'Admin'] }, + { label: 'Message Groups', path: '/message-groups', roles: ['User', 'Admin'] }, + { label: 'Media', path: '/media', roles: ['User', 'Admin'] }, + { label: 'Posts', path: '/posts', roles: ['User', 'Admin'] }, + { label: 'Docker', path: '/docker', roles: ['Admin'] }, + ]; + + return ( + + + + PHS Admin + + + {navItems.map((item) => ( + (!user || item.roles.some(role => user.roles?.includes(role))) && ( + + ) + ))} + {user ? ( + + ) : ( + + )} + + + + ); +}; + +export default AppHeader; \ No newline at end of file diff --git a/src/app/components/AuthContext.js b/src/app/components/AuthContext.js new file mode 100644 index 0000000..290b9a9 --- /dev/null +++ b/src/app/components/AuthContext.js @@ -0,0 +1,59 @@ +/** + * @file Authentication context for user and role data + */ + +import React, { createContext, useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSnackbar } from 'notistack'; +import { AuthService } from '../services/auth'; + +export const AuthContext = createContext(); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const navigate = useNavigate(); + const { enqueueSnackbar } = useSnackbar(); + + useEffect(() => { + const fetchUser = async () => { + try { + const token = localStorage.getItem('token'); + if (token) { + const response = await AuthService.getCurrentUser(); + setUser(response.user); + } + } catch (error) { + enqueueSnackbar(`Error fetching user: ${error.message}`, { variant: 'error' }); + } + }; + fetchUser(); + }, [enqueueSnackbar]); + + const login = async (email, password) => { + try { + const response = await AuthService.login(email, password); + if (response.success) { + localStorage.setItem('token', response.token); + setUser(response.user); + navigate('/'); + return true; + } + return false; + } catch (error) { + enqueueSnackbar(`Login error: ${error.message}`, { variant: 'error' }); + return false; + } + }; + + const logout = () => { + localStorage.removeItem('token'); + setUser(null); + navigate('/login'); + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/app/components/MediaForm.js b/src/app/components/MediaForm.js new file mode 100644 index 0000000..7cd1f3e --- /dev/null +++ b/src/app/components/MediaForm.js @@ -0,0 +1,120 @@ +/** + * @file Component for creating a new media item + */ + +import React from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { TextField, MenuItem, Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useNavigate } from 'react-router-dom'; +import { MediaService } from '../services'; + +const schema = yup.object({ + user_id: yup.number().required('User ID is required'), + file_path: yup.string().required('File path is required'), + file_type: yup.string().required('File type is required'), + visibility: yup.string().oneOf(['private', 'family', 'public']).required('Visibility is required'), +}).required(); + +const MediaForm = () => { + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const { control, handleSubmit, formState: { errors } } = useForm({ + resolver: yupResolver(schema), + }); + + const onSubmit = async (data) => { + try { + await MediaService.createMedia(data); + enqueueSnackbar('Media created successfully', { variant: 'success' }); + navigate('/media'); + } catch (error) { + enqueueSnackbar(`Error creating media: ${error.message}`, { variant: 'error' }); + } + }; + + return ( +
+

Upload Media

+
+ ( + + )} + /> + ( + + )} + /> + ( + + Image + Video + + )} + /> + ( + + Private + Family + Public + + )} + /> + + +
+ ); +}; + +export default MediaForm; \ No newline at end of file diff --git a/src/app/components/MediaTable.js b/src/app/components/MediaTable.js new file mode 100644 index 0000000..b1faff2 --- /dev/null +++ b/src/app/components/MediaTable.js @@ -0,0 +1,97 @@ +/** + * @file Component for displaying media in a table + */ + +import React, { useMemo, useState } from 'react'; +import { MaterialReactTable } from 'material-react-table'; +import { Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useNavigate } from 'react-router-dom'; +import { mediaService } from '../services'; +import { formatDate } from '../utils'; + +const MediaTable = () => { + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const [mediaItems, setMediaItems] = useState([]); + + // Fetch media on mount + React.useEffect(() => { + const fetchMedia = async () => { + try { + const response = await mediaService.getMedia(); + setMediaItems(response.data); + } catch (error) { + enqueueSnackbar(`Error fetching media: ${error.message}`, { variant: 'error' }); + } + }; + fetchMedia(); + }, [enqueueSnackbar]); + + const columns = useMemo(() => [ + { + accessorKey: 'id', + header: 'ID', + size: 80, + }, + { + accessorKey: 'user_id', + header: 'User ID', + size: 100, + }, + { + accessorKey: 'file_path', + header: 'File Path', + size: 200, + }, + { + accessorKey: 'file_type', + header: 'Type', + size: 100, + }, + { + accessorKey: 'visibility', + header: 'Visibility', + size: 100, + }, + { + accessorKey: 'created_at', + header: 'Created At', + size: 150, + Cell: ({ cell }) => formatDate(cell.getValue()), + }, + ], []); + + return ( +
+

Media

+ + +
+ ); +}; + +export default MediaTable; \ No newline at end of file diff --git a/src/app/components/MessageForm.js b/src/app/components/MessageForm.js new file mode 100644 index 0000000..dbc2f00 --- /dev/null +++ b/src/app/components/MessageForm.js @@ -0,0 +1,104 @@ +/** + * @file Component for creating a new message + */ + +import React from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { TextField, Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useNavigate } from 'react-router-dom'; +import { messageService } from '../services'; + +const schema = yup.object({ + recipient_id: yup.number().nullable().when('group_id', { + is: (group_id) => !group_id, + then: yup.number().required('Either recipient_id or group_id is required'), + }), + group_id: yup.number().nullable().when('recipient_id', { + is: (recipient_id) => !recipient_id, + then: yup.number().required('Either recipient_id or group_id is required'), + }), + content: yup.string().required('Content is required'), +}).required(); + +const MessageForm = () => { + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const { control, handleSubmit, formState: { errors } } = useForm({ + resolver: yupResolver(schema), + }); + + const onSubmit = async (data) => { + try { + await messageService.createMessage(data); + enqueueSnackbar('Message created successfully', { variant: 'success' }); + navigate('/messages'); + } catch (error) { + enqueueSnackbar(`Error creating message: ${error.message}`, { variant: 'error' }); + } + }; + + return ( +
+

New Message

+
+ ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + +
+ ); +}; + +export default MessageForm; \ No newline at end of file diff --git a/src/app/components/MessageGroupForm.js b/src/app/components/MessageGroupForm.js new file mode 100644 index 0000000..b312a55 --- /dev/null +++ b/src/app/components/MessageGroupForm.js @@ -0,0 +1,79 @@ +/** + * @file Component for creating a new message group + */ + +import React from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { TextField, Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useNavigate } from 'react-router-dom'; +import { MessageGroupService } from '../services'; + +const schema = yup.object({ + name: yup.string().required('Name is required'), + created_by_id: yup.number().required('Created by ID is required'), +}).required(); + +const MessageGroupForm = () => { + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const { control, handleSubmit, formState: { errors } } = useForm({ + resolver: yupResolver(schema), + }); + + const onSubmit = async (data) => { + try { + await MessageGroupService.createMessageGroup(data); + enqueueSnackbar('Message group created successfully', { variant: 'success' }); + navigate('/message-groups'); + } catch (error) { + enqueueSnackbar(`Error creating message group: ${error.message}`, { variant: 'error' }); + } + }; + + return ( +
+

New Message Group

+
+ ( + + )} + /> + ( + + )} + /> + + +
+ ); +}; + +export default MessageGroupForm; \ No newline at end of file diff --git a/src/app/components/MessageGroupMemberForm.js b/src/app/components/MessageGroupMemberForm.js new file mode 100644 index 0000000..5e46d1c --- /dev/null +++ b/src/app/components/MessageGroupMemberForm.js @@ -0,0 +1,64 @@ +/** + * @file Component for adding a message group member + */ + +import React from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { TextField, Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useNavigate, useParams } from 'react-router-dom'; +import { MessageGroupMembersService } from '../services'; + +const schema = yup.object({ + user_id: yup.number().required('User ID is required'), +}).required(); + +const MessageGroupMemberForm = () => { + const { group_id } = useParams(); + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const { control, handleSubmit, formState: { errors } } = useForm({ + resolver: yupResolver(schema), + }); + + const onSubmit = async (data) => { + try { + await MessageGroupMembersService.addMember({ ...data, group_id }); + enqueueSnackbar('Member added successfully', { variant: 'success' }); + navigate(`/message-groups/${group_id}/members`); + } catch (error) { + enqueueSnackbar(`Error adding member: ${error.message}`, { variant: 'error' }); + } + }; + + return ( +
+

Add Member to Group

+
+ ( + + )} + /> + + +
+ ); +}; + +export default MessageGroupMemberForm; \ No newline at end of file diff --git a/src/app/components/MessageGroupMembersTable.js b/src/app/components/MessageGroupMembersTable.js new file mode 100644 index 0000000..8fe0c52 --- /dev/null +++ b/src/app/components/MessageGroupMembersTable.js @@ -0,0 +1,101 @@ +/** + * @file Component for displaying message group members in a table + */ + +import React, { useMemo, useState } from 'react'; +import { MaterialReactTable } from 'material-react-table'; +import { Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useParams, useNavigate } from 'react-router-dom'; +import { messageGroupMembersService } from '../services'; + +const MessageGroupMembersTable = () => { + const { group_id } = useParams(); + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const [members, setMembers] = useState([]); + + // Fetch members on mount + React.useEffect(() => { + const fetchMembers = async () => { + try { + const response = await messageGroupMembersService.getMembersByGroupId(group_id); + setMembers(response.data); + } catch (error) { + enqueueSnackbar(`Error fetching group members: ${error.message}`, { variant: 'error' }); + } + }; + fetchMembers(); + }, [group_id, enqueueSnackbar]); + + // Remove member + const handleRemoveMember = async (user_id) => { + try { + await messageGroupMembersService.removeMember({ user_id, group_id }); + setMembers(members.filter(member => member.user_id !== user_id)); + enqueueSnackbar('Member removed successfully', { variant: 'success' }); + } catch (error) { + enqueueSnackbar(`Error removing member: ${error.message}`, { variant: 'error' }); + } + }; + + const columns = useMemo(() => [ + { + accessorKey: 'user_id', + header: 'User ID', + size: 100, + }, + { + accessorKey: 'group_id', + header: 'Group ID', + size: 100, + }, + { + accessorKey: 'user_id', + header: 'Actions', + size: 100, + Cell: ({ cell }) => ( + + ), + }, + ], [members]); + + return ( +
+

Message Group Members

+ + +
+ ); +}; + +export default MessageGroupMembersTable; \ No newline at end of file diff --git a/src/app/components/MessageGroupsTable.js b/src/app/components/MessageGroupsTable.js new file mode 100644 index 0000000..e962ad0 --- /dev/null +++ b/src/app/components/MessageGroupsTable.js @@ -0,0 +1,110 @@ +/** + * @file Component for displaying message groups in a table + */ + +import React, { useMemo, useState } from 'react'; +import { MaterialReactTable } from 'material-react-table'; +import { Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useNavigate } from 'react-router-dom'; +import { messageGroupService } from '../services'; +import { formatDate } from '../utils'; + +const MessageGroupsTable = () => { + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const [messageGroups, setMessageGroups] = useState([]); + + // Fetch message groups on mount + React.useEffect(() => { + const fetchMessageGroups = async () => { + try { + const response = await messageGroupService.getMessageGroups(); + setMessageGroups(response.data); + } catch (error) { + enqueueSnackbar(`Error fetching message groups: ${error.message}`, { variant: 'error' }); + } + }; + fetchMessageGroups(); + }, [enqueueSnackbar]); + + const columns = useMemo(() => [ + { + accessorKey: 'id', + header: 'ID', + size: 80, + }, + { + accessorKey: 'name', + header: 'Name', + size: 200, + }, + { + accessorKey: 'created_by_id', + header: 'Created By', + size: 100, + }, + { + accessorKey: 'created_at', + header: 'Created At', + size: 150, + Cell: ({ cell }) => formatDate(cell.getValue()), + }, + { + accessorKey: 'id', + header: 'Actions', + size: 150, + Cell: ({ cell }) => ( +
+ + +
+ ), + }, + ], []); + + return ( +
+

Message Groups

+ + +
+ ); +}; + +export default MessageGroupsTable; \ No newline at end of file diff --git a/src/app/components/MessagesTable.js b/src/app/components/MessagesTable.js new file mode 100644 index 0000000..f76ba74 --- /dev/null +++ b/src/app/components/MessagesTable.js @@ -0,0 +1,129 @@ +/** + * @file Component for displaying messages in a table + */ + +import React, { useMemo, useState } from 'react'; +import { MaterialReactTable } from 'material-react-table'; +import { Button, IconButton } from '@mui/material'; +import { MarkEmailRead } from '@mui/icons-material'; +import { useSnackbar } from 'notistack'; +import { useNavigate } from 'react-router-dom'; +import { messageService } from '../services'; +import { formatDate } from '../utils'; + +const MessagesTable = ({ groupId, recipientId }) => { + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const [messages, setMessages] = useState([]); + + // Fetch messages on mount + React.useEffect(() => { + const fetchMessages = async () => { + try { + let response; + if (groupId) { + response = await messageService.getMessagesByGroupId(groupId); + } else if (recipientId) { + response = await messageService.getMessagesByRecipientId(recipientId); + } else { + response = await messageService.getMessages(); + } + setMessages(response.data); + } catch (error) { + enqueueSnackbar(`Error fetching messages: ${error.message}`, { variant: 'error' }); + } + }; + fetchMessages(); + }, [enqueueSnackbar, groupId, recipientId]); + + // Mark message as read + const handleMarkAsRead = async (messageId) => { + try { + const response = await messageService.markMessageAsRead(messageId); + setMessages(messages.map(msg => msg.id === messageId ? response.data : msg)); + enqueueSnackbar('Message marked as read', { variant: 'success' }); + } catch (error) { + enqueueSnackbar(`Error marking message as read: ${error.message}`, { variant: 'error' }); + } + }; + + const columns = useMemo(() => [ + { + accessorKey: 'sender_id', + header: 'Sender ID', + size: 100, + }, + { + accessorKey: 'content', + header: 'Content', + size: 300, + }, + { + accessorKey: 'read', + header: 'Read', + size: 80, + Cell: ({ cell }) => (cell.getValue() ? 'Yes' : 'No'), + }, + { + accessorKey: 'read_at', + header: 'Read At', + size: 150, + Cell: ({ cell }) => formatDate(cell.getValue()), + }, + { + accessorKey: 'created_at', + header: 'Created At', + size: 150, + Cell: ({ cell }) => formatDate(cell.getValue()), + }, + { + accessorKey: 'id', + header: 'Actions', + size: 100, + Cell: ({ cell }) => ( + !cell.row.original.read && ( + handleMarkAsRead(cell.getValue())} + title="Mark as Read" + > + + + ) + ), + }, + ], [messages]); + + return ( +
+

Messages

+ + +
+ ); +}; + +export default MessagesTable; \ No newline at end of file diff --git a/src/app/components/PostForm.js b/src/app/components/PostForm.js new file mode 100644 index 0000000..24e8ec6 --- /dev/null +++ b/src/app/components/PostForm.js @@ -0,0 +1,138 @@ +/** + * @file Component for creating a new post + */ + +import React from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { TextField, MenuItem, Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useNavigate } from 'react-router-dom'; +import { PostService } from '../services'; + +const schema = yup.object({ + user_id: yup.number().required('User ID is required'), + title: yup.string().required('Title is required'), + content: yup.string().required('Content is required'), + post_type: yup.string().oneOf(['blog', 'vlog']).required('Post type is required'), + visibility: yup.string().oneOf(['private', 'family', 'public']).required('Visibility is required'), +}).required(); + +const PostForm = () => { + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const { control, handleSubmit, formState: { errors } } = useForm({ + resolver: yupResolver(schema), + }); + + const onSubmit = async (data) => { + try { + await PostService.createPost(data); + enqueueSnackbar('Post created successfully', { variant: 'success' }); + navigate('/posts'); + } catch (error) { + enqueueSnackbar(`Error creating post: ${error.message}`, { variant: 'error' }); + } + }; + + return ( +
+

Create Post

+
+ ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + Blog + Vlog + + )} + /> + ( + + Private + Family + Public + + )} + /> + + +
+ ); +}; + +export default PostForm; \ No newline at end of file diff --git a/src/app/components/PostsTable.js b/src/app/components/PostsTable.js new file mode 100644 index 0000000..e344a04 --- /dev/null +++ b/src/app/components/PostsTable.js @@ -0,0 +1,102 @@ +/** + * @file Component for displaying posts in a table + */ + +import React, { useMemo, useState } from 'react'; +import { MaterialReactTable } from 'material-react-table'; +import { Button } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useNavigate } from 'react-router-dom'; +import { postService } from '../services'; +import { formatDate } from '../utils'; + +const PostsTable = () => { + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const [posts, setPosts] = useState([]); + + // Fetch posts on mount + React.useEffect(() => { + const fetchPosts = async () => { + try { + const response = await postService.getPosts(); + setPosts(response.data); + } catch (error) { + enqueueSnackbar(`Error fetching posts: ${error.message}`, { variant: 'error' }); + } + }; + fetchPosts(); + }, [enqueueSnackbar]); + + const columns = useMemo(() => [ + { + accessorKey: 'id', + header: 'ID', + size: 80, + }, + { + accessorKey: 'user_id', + header: 'User ID', + size: 100, + }, + { + accessorKey: 'title', + header: 'Title', + size: 200, + }, + { + accessorKey: 'content', + header: 'Content', + size: 300, + }, + { + accessorKey: 'post_type', + header: 'Type', + size: 100, + }, + { + accessorKey: 'visibility', + header: 'Visibility', + size: 100, + }, + { + accessorKey: 'created_at', + header: 'Created At', + size: 150, + Cell: ({ cell }) => formatDate(cell.getValue()), + }, + ], []); + + return ( +
+

Posts

+ + +
+ ); +}; + +export default PostsTable; \ No newline at end of file diff --git a/src/app/routes.js b/src/app/routes.js index f31818d..8547ad6 100755 --- a/src/app/routes.js +++ b/src/app/routes.js @@ -1,6 +1,17 @@ import { Route, Routes } from 'react-router-dom'; import { Login, NotFound, Dashboard, Git, VPN, Docker } from './views'; import { VPNProvider } from './components/'; +import MessagesView from './views/Messages/MessagesView'; +import MessageFormView from './views/Messages/MessageFormView'; +import ChatView from './views/Messages/ChatView'; +import MessageGroupsView from './views/MessageGroups/MessageGroupsView'; +import MessageGroupFormView from './views/MessageGroups/MessageGroupFormView'; +import MessageGroupMembersView from './views/MessageGroupMembers/MessageGroupMembersView'; +import MessageGroupMemberFormView from './views/MessageGroupMembers/MessageGroupMemberFormView'; +import MediaView from './views/Media/MediaView'; +import MediaFormView from './views/Media/MediaFormView'; +import PostsView from './views/Posts/PostsView'; +import PostFormView from './views/Posts/PostFormView'; export default function AppRoutes() { return ( @@ -10,6 +21,18 @@ export default function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> ); diff --git a/src/app/services/auth.js b/src/app/services/auth.js new file mode 100644 index 0000000..de99b9a --- /dev/null +++ b/src/app/services/auth.js @@ -0,0 +1,22 @@ +/** + * @file Authentication service for login API calls + */ + +import axios from 'axios'; + +const BASE_URL = 'http://localhost:3601'; + +const getAuthHeaders = () => ({ + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, +}); + +export const AuthService = { + async login(email, password) { + const response = await axios.post(`${BASE_URL}/auth/authenticate`, { email, password }); + return response.data; + }, + async getCurrentUser() { + const response = await axios.get(`${BASE_URL}/user/1`, getAuthHeaders()); + return { user: response.data }; + }, +}; \ No newline at end of file diff --git a/src/app/services/index.js b/src/app/services/index.js index 9c81015..24eb00d 100644 --- a/src/app/services/index.js +++ b/src/app/services/index.js @@ -2,4 +2,10 @@ import DockerService from './Docker/DockerService'; import GitService from './Git/GitService'; import VPNService from './VPN/VPNService'; + +export { MessageService } from './messages'; +export { MediaService } from './media'; +export { MessageGroupService } from './message_groups'; +export { MessageGroupMembersService } from './message_group_members'; +export { PostService } from './posts'; export { DockerService, GitService, VPNService }; \ No newline at end of file diff --git a/src/app/services/media.js b/src/app/services/media.js new file mode 100644 index 0000000..9057215 --- /dev/null +++ b/src/app/services/media.js @@ -0,0 +1,32 @@ +/** + * @file Media service for API calls + */ + +import axios from 'axios'; + +const BASE_URL = 'http://localhost:3601'; + +const getAuthHeaders = () => ({ + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, +}); + +export const MediaService = { + async getMedia(params = {}) { + return await axios.get(`${BASE_URL}/media`, { ...getAuthHeaders(), params }); + }, + async getMediaByFilePath(file_path) { + return await axios.get(`${BASE_URL}/media/file_path/${file_path}`, getAuthHeaders()); + }, + async getMediaById(id) { + return await axios.get(`${BASE_URL}/media/${id}`, getAuthHeaders()); + }, + async createMedia(data) { + return await axios.post(`${BASE_URL}/media/create`, data, getAuthHeaders()); + }, + async updateMedia(id, data) { + return await axios.put(`${BASE_URL}/media/${id}`, data, getAuthHeaders()); + }, + async softDeleteMedia(id, data) { + return await axios.put(`${BASE_URL}/media/${id}/soft_delete`, data, getAuthHeaders()); + }, +}; \ No newline at end of file diff --git a/src/app/services/message_group_members.js b/src/app/services/message_group_members.js new file mode 100644 index 0000000..0d745bf --- /dev/null +++ b/src/app/services/message_group_members.js @@ -0,0 +1,26 @@ +/** + * @file Message group members service for API calls + */ + +import axios from 'axios'; + +const BASE_URL = 'http://localhost:3601'; + +const getAuthHeaders = () => ({ + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, +}); + +export const MessageGroupMembersService = { + async getMembersByGroupId(group_id, params = {}) { + return await axios.get(`${BASE_URL}/message_group_members/group/${group_id}`, { ...getAuthHeaders(), params }); + }, + async getMemberByIds(group_id, user_id) { + return await axios.get(`${BASE_URL}/message_group_members/ids/${group_id}/${user_id}`, getAuthHeaders()); + }, + async addMember(data) { + return await axios.post(`${BASE_URL}/message_group_members/add`, data, getAuthHeaders()); + }, + async removeMember(data) { + return await axios.delete(`${BASE_URL}/message_group_members/remove`, { ...getAuthHeaders(), data }); + }, +}; \ No newline at end of file diff --git a/src/app/services/message_groups.js b/src/app/services/message_groups.js new file mode 100644 index 0000000..42ab639 --- /dev/null +++ b/src/app/services/message_groups.js @@ -0,0 +1,26 @@ +/** + * @file Message group service for API calls + */ + +import axios from 'axios'; + +const BASE_URL = 'http://localhost:3601'; + +const getAuthHeaders = () => ({ + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, +}); + +export const MessageGroupService = { + async getMessageGroups(params = {}) { + return await axios.get(`${BASE_URL}/message_group`, { ...getAuthHeaders(), params }); + }, + async getMessageGroup(id) { + return await axios.get(`${BASE_URL}/message_group/${id}`, getAuthHeaders()); + }, + async createMessageGroup(data) { + return await axios.post(`${BASE_URL}/message_group/create`, data, getAuthHeaders()); + }, + async updateMessageGroup(id, data) { + return await axios.put(`${BASE_URL}/message_group/${id}`, data, getAuthHeaders()); + }, +}; \ No newline at end of file diff --git a/src/app/services/messages.js b/src/app/services/messages.js new file mode 100644 index 0000000..decbe25 --- /dev/null +++ b/src/app/services/messages.js @@ -0,0 +1,32 @@ +/** + * @file Message service for API calls + */ + +import axios from 'axios'; + +const BASE_URL = 'http://localhost:3601'; + +const getAuthHeaders = () => ({ + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, +}); + +export const MessageService = { + async getMessages(params = {}) { + return await axios.get(`${BASE_URL}/message`, { ...getAuthHeaders(), params }); + }, + async getMessagesByGroupId(group_id, params = {}) { + return await axios.get(`${BASE_URL}/message/group/${group_id}`, { ...getAuthHeaders(), params }); + }, + async getMessagesByRecipientId(recipient_id, params = {}) { + return await axios.get(`${BASE_URL}/message/recipient/${recipient_id}`, { ...getAuthHeaders(), params }); + }, + async getMessage(id) { + return await axios.get(`${BASE_URL}/message/${id}`, getAuthHeaders()); + }, + async createMessage(data) { + return await axios.post(`${BASE_URL}/message/create`, data, getAuthHeaders()); + }, + async markMessageAsRead(id) { + return await axios.put(`${BASE_URL}/message/${id}/mark_as_read`, {}, getAuthHeaders()); + }, +}; \ No newline at end of file diff --git a/src/app/services/posts.js b/src/app/services/posts.js new file mode 100644 index 0000000..a3211fc --- /dev/null +++ b/src/app/services/posts.js @@ -0,0 +1,32 @@ +/** + * @file Post service for API calls + */ + +import axios from 'axios'; + +const BASE_URL = 'http://localhost:3601'; + +const getAuthHeaders = () => ({ + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, +}); + +export const PostService = { + async getPosts(params = {}) { + return await axios.get(`${BASE_URL}/post`, { ...getAuthHeaders(), params }); + }, + async getPostByTitle(title) { + return await axios.get(`${BASE_URL}/post/title/${title}`, getAuthHeaders()); + }, + async getPost(id) { + return await axios.get(`${BASE_URL}/post/${id}`, getAuthHeaders()); + }, + async createPost(data) { + return await axios.post(`${BASE_URL}/post/create`, data, getAuthHeaders()); + }, + async updatePost(id, data) { + return await axios.put(`${BASE_URL}/post/${id}`, data, getAuthHeaders()); + }, + async softDeletePost(id, data) { + return await axios.put(`${BASE_URL}/post/${id}/soft_delete`, data, getAuthHeaders()); + }, +}; \ No newline at end of file diff --git a/src/app/views/Dashboard/Dashboard.jsx b/src/app/views/Dashboard/Dashboard.jsx index 6157ab8..ebf3904 100755 --- a/src/app/views/Dashboard/Dashboard.jsx +++ b/src/app/views/Dashboard/Dashboard.jsx @@ -1,98 +1,60 @@ -import React, { useState, useEffect } from 'react'; -import { useMaterialReactTable, MaterialReactTable } from 'material-react-table'; -import { Container, IconButton } from '@mui/material'; -import { VisibilityOutlined } from '@mui/icons-material'; -// import { FormService } from '../../services'; -// import { FormDetail } from '../../components'; -import { getFormattedDate } from '../../utils'; - -const Dashboard = () => { - const [data, setData] = useState( [] ); - const [selectedFormID, setSelectedFormID] = useState( null ); - const [detailOpen, setDetailOpen] = useState( false ); - const [selectedForm, setSelectedForm] = useState( null ); +/** + * @file Dashboard view with navigation to main features + */ +import React, { useContext } from 'react'; +import { Container, Grid, Card, CardContent, Typography, Button } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { AuthContext } from '../../components/AuthContext'; +import AppHeader from '../../components/AppHeader'; - // useEffect( () => { - // FormService.get( null, null ).then( response => { - // console.log('response', response); - // setData(response); - // }); - // }, [] ); - // - // useEffect( () => { - // if (selectedFormID) { - // FormService.get( null, selectedFormID ).then( response => { - // setSelectedForm( response ); - // }); - // } - // }, [selectedFormID] ); - - const table = useMaterialReactTable({ - columns: [ - { - header: 'Actions', - id: 'woActions', - enableSorting: false, - enableGlobalFilter: false, - enableColumnFilter: false, - enableGrouping: false, - enableEditing: false, - size: 150, - accessorFn: (row) => { - const showSelectedModal = () => { - setSelectedFormID(row.id) - setDetailOpen(true); - }; +const Dashboard = () => { + const navigate = useNavigate(); + const { user } = useContext(AuthContext); - return( - - - - ) - }, - }, - { - header: 'ID', - accessorKey: 'id', //access nested data with dot notation - }, - { - header:'Unit#', - accessorKey: 'unit_number', //simple function - }, - { - header: 'Employee Name', - accessorKey: 'employee_name' - }, - { - header: 'Employee Number', - accessorKey: 'employee_number' - }, - { - header: 'Email Address', - accessorKey: 'email_address' - }, - { - header: 'Photos', - accessorKey: 'photo_count' - }, - { - header: 'Date', - accessorKey: 'created_at', - accessorFn: (row) => { - return getFormattedDate(row.created_at); - } - } - ], - data - }); - return + const features = [ + { title: 'Messages', path: '/messages', description: 'View and send messages', roles: ['User', 'Admin'] }, + { title: 'Message Groups', path: '/message-groups', description: 'Manage group chats', roles: ['User', 'Admin'] }, + { title: 'Media', path: '/media', description: 'Upload and view media', roles: ['User', 'Admin'] }, + { title: 'Posts', path: '/posts', description: 'Create and view posts', roles: ['User', 'Admin'] }, + { title: 'Docker', path: '/docker', description: 'Manage Docker containers', roles: ['Admin'] }, + ]; - {/**/} - - ; + return ( +
+ + + + Welcome to PHS Admin{user ? `, ${user.first_name || 'User ' + user.id}` : ''} + + + {features.map((feature) => ( + (!user || feature.roles.some(role => user.roles?.includes(role))) && ( + + + + + {feature.title} + + + {feature.description} + + + + + + ) + ))} + + +
+ ); }; export default Dashboard; \ No newline at end of file diff --git a/src/app/views/Login/Login.jsx b/src/app/views/Login/Login.jsx index 8ada8cf..8b05178 100755 --- a/src/app/views/Login/Login.jsx +++ b/src/app/views/Login/Login.jsx @@ -1,242 +1,85 @@ -import { useState } from 'react'; +/** + * @file Login view with authentication form + */ + +import React, { useContext } from 'react'; +import { Container, TextField, Button, Typography, Box } from '@mui/material'; +import { useForm, Controller } from 'react-hook-form'; 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 { useSnackbar } from 'notistack'; import { useNavigate } from 'react-router-dom'; +import { AuthContext } from '../../components/AuthContext'; +import AppHeader from '../../components/AppHeader'; -/** - * 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 schema = yup.object({ + email: yup.string().email('Invalid email').required('Email is required'), + password: yup.string().required('Password is required'), +}).required(); -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 Login = () => { + const { enqueueSnackbar } = useSnackbar(); 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); - } );*/ - } + const { login } = useContext(AuthContext); + const { control, handleSubmit, formState: { errors } } = useForm({ + resolver: yupResolver(schema), + }); + + const onSubmit = async (data) => { + const success = await login(data.email, data.password); + if (success) { + enqueueSnackbar('Login successful', { variant: 'success' }); + } else { + enqueueSnackbar('Invalid credentials', { variant: 'error' }); + } + }; return ( -
- -
- - - Sign in - - - {signInError && - - {signInError} - - } - -
- ( - - ) } - /> - - ( - - setShowPassword( prevState => !prevState ) }> - { showPassword ? : } - - - }} - variant="outlined" - required - fullWidth - /> - ) } - /> - {errors?.password && ( - - {errors.password.message} - +
+ + + + Login to PHS Admin + + + ( + )} - -
- ( - - } - /> - - ) } + /> + ( + -
- - - - - - -
- - - - - - - - - - - - - - - - + )} + /> + - -
-
-
Please Login
-
-
- Enter your company employee number and password to log in. -
-
-
+
); -} +}; -export default SignInPage; +export default Login; \ No newline at end of file diff --git a/src/app/views/Media/MediaFormView.js b/src/app/views/Media/MediaFormView.js new file mode 100644 index 0000000..766900e --- /dev/null +++ b/src/app/views/Media/MediaFormView.js @@ -0,0 +1,16 @@ +/** + * @file View for creating a new media item + */ + +import React from 'react'; +import MediaForm from '../../components/MediaForm'; + +const MediaFormView = () => { + return ( +
+ +
+ ); +}; + +export default MediaFormView; \ No newline at end of file diff --git a/src/app/views/Media/MediaView.js b/src/app/views/Media/MediaView.js new file mode 100644 index 0000000..e465f3e --- /dev/null +++ b/src/app/views/Media/MediaView.js @@ -0,0 +1,16 @@ +/** + * @file View for displaying media + */ + +import React from 'react'; +import MediaTable from '../../components/MediaTable'; + +const MediaView = () => { + return ( +
+ +
+ ); +}; + +export default MediaView; \ No newline at end of file diff --git a/src/app/views/MessageGroupMembers/MessageGroupMemberFormView.js b/src/app/views/MessageGroupMembers/MessageGroupMemberFormView.js new file mode 100644 index 0000000..bc1082d --- /dev/null +++ b/src/app/views/MessageGroupMembers/MessageGroupMemberFormView.js @@ -0,0 +1,16 @@ +/** + * @file View for adding a message group member + */ + +import React from 'react'; +import MessageGroupMemberForm from '../../components/MessageGroupMemberForm'; + +const MessageGroupMemberFormView = () => { + return ( +
+ +
+ ); +}; + +export default MessageGroupMemberFormView; \ No newline at end of file diff --git a/src/app/views/MessageGroupMembers/MessageGroupMembersView.js b/src/app/views/MessageGroupMembers/MessageGroupMembersView.js new file mode 100644 index 0000000..71c532f --- /dev/null +++ b/src/app/views/MessageGroupMembers/MessageGroupMembersView.js @@ -0,0 +1,16 @@ +/** + * @file View for displaying message group members + */ + +import React from 'react'; +import MessageGroupMembersTable from '../../components/MessageGroupMembersTable'; + +const MessageGroupMembersView = () => { + return ( +
+ +
+ ); +}; + +export default MessageGroupMembersView; \ No newline at end of file diff --git a/src/app/views/MessageGroups/MessageGroupFormView.js b/src/app/views/MessageGroups/MessageGroupFormView.js new file mode 100644 index 0000000..3572fc8 --- /dev/null +++ b/src/app/views/MessageGroups/MessageGroupFormView.js @@ -0,0 +1,16 @@ +/** + * @file View for creating a new message group + */ + +import React from 'react'; +import MessageGroupForm from '../../components/MessageGroupForm'; + +const MessageGroupFormView = () => { + return ( +
+ +
+ ); +}; + +export default MessageGroupFormView; \ No newline at end of file diff --git a/src/app/views/MessageGroups/MessageGroupsView.js b/src/app/views/MessageGroups/MessageGroupsView.js new file mode 100644 index 0000000..4715eca --- /dev/null +++ b/src/app/views/MessageGroups/MessageGroupsView.js @@ -0,0 +1,16 @@ +/** + * @file View for displaying message groups + */ + +import React from 'react'; +import MessageGroupsTable from '../../components/MessageGroupsTable'; + +const MessageGroupsView = () => { + return ( +
+ +
+ ); +}; + +export default MessageGroupsView; \ No newline at end of file diff --git a/src/app/views/Messages/ChatView.js b/src/app/views/Messages/ChatView.js new file mode 100644 index 0000000..80d3276 --- /dev/null +++ b/src/app/views/Messages/ChatView.js @@ -0,0 +1,121 @@ +/** + * @file View for displaying messages in a chat-like interface + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { TextField, Button, Typography } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useParams, useNavigate } from 'react-router-dom'; +import { MessageService } from '../../services'; +import { formatDate, truncateContent } from '../../utils'; + +const ChatView = () => { + const { group_id, recipient_id } = useParams(); + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const messagesEndRef = useRef(null); + + // Fetch messages + useEffect(() => { + const fetchMessages = async () => { + try { + let response; + if (group_id) { + response = await MessageService.getMessagesByGroupId(group_id); + } else if (recipient_id) { + response = await MessageService.getMessagesByRecipientId(recipient_id); + } + setMessages(response.data); + } catch (error) { + enqueueSnackbar(`Error fetching messages: ${error.message}`, { variant: 'error' }); + } + }; + fetchMessages(); + }, [group_id, recipient_id, enqueueSnackbar]); + + // Scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Send message + const handleSendMessage = async () => { + if (!newMessage.trim()) return; + try { + const data = { + sender_id: JSON.parse(localStorage.getItem('user'))?.id || 1, // Adjust based on auth + group_id: group_id ? parseInt(group_id) : null, + recipient_id: recipient_id ? parseInt(recipient_id) : null, + content: newMessage, + }; + const response = await MessageService.createMessage(data); + setMessages([...messages, response.data]); + setNewMessage(''); + enqueueSnackbar('Message sent successfully', { variant: 'success' }); + } catch (error) { + enqueueSnackbar(`Error sending message: ${error.message}`, { variant: 'error' }); + } + }; + + // Mark message as read + const handleMarkAsRead = async (messageId) => { + try { + const user = JSON.parse(localStorage.getItem('user')) || { id: 1 }; // Adjust based on auth + const message = messages.find(msg => msg.id === messageId); + if (user.id !== message.recipient_id && !group_id) { + enqueueSnackbar('Only the recipient can mark this message as read', { variant: 'error' }); + return; + } + const response = await MessageService.markMessageAsRead(messageId); + setMessages(messages.map(msg => msg.id === messageId ? response.data : msg)); + enqueueSnackbar('Message marked as read', { variant: 'success' }); + } catch (error) { + enqueueSnackbar(`Error marking message as read: ${error.message}`, { variant: 'error' }); + } + }; + + return ( +
+

+ {group_id ? `Group Chat (ID: ${group_id})` : `Direct Chat (Recipient ID: ${recipient_id})`} +

+
+ {messages.map((msg) => ( +
!msg.read && handleMarkAsRead(msg.id)} + > + + Sender: {msg.sender_id} | {formatDate(msg.created_at)} + + {truncateContent(msg.content, 200)} + {msg.read && ( + + Read at: {formatDate(msg.read_at)} + + )} +
+ ))} +
+
+
+ setNewMessage(e.target.value)} + placeholder="Type a message..." + fullWidth + multiline + rows={2} + /> + +
+
+ ); +}; + +export default ChatView; \ No newline at end of file diff --git a/src/app/views/Messages/MessageFormView.js b/src/app/views/Messages/MessageFormView.js new file mode 100644 index 0000000..9149838 --- /dev/null +++ b/src/app/views/Messages/MessageFormView.js @@ -0,0 +1,16 @@ +/** + * @file View for creating a new message + */ + +import React from 'react'; +import MessageForm from '../../components/MessageForm'; + +const MessageFormView = () => { + return ( +
+ +
+ ); +}; + +export default MessageFormView; \ No newline at end of file diff --git a/src/app/views/Messages/MessagesView.js b/src/app/views/Messages/MessagesView.js new file mode 100644 index 0000000..488eef1 --- /dev/null +++ b/src/app/views/Messages/MessagesView.js @@ -0,0 +1,21 @@ +/** + * @file View for displaying messages + */ + +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import MessagesTable from '../../components/MessagesTable'; + +const MessagesView = () => { + const [searchParams] = useSearchParams(); + const groupId = searchParams.get('group_id'); + const recipientId = searchParams.get('recipient_id'); + + return ( +
+ +
+ ); +}; + +export default MessagesView; \ No newline at end of file diff --git a/src/app/views/Posts/PostFormView.js b/src/app/views/Posts/PostFormView.js new file mode 100644 index 0000000..e6fbdab --- /dev/null +++ b/src/app/views/Posts/PostFormView.js @@ -0,0 +1,16 @@ +/** + * @file View for creating a new post + */ + +import React from 'react'; +import PostForm from '../../components/PostForm'; + +const PostFormView = () => { + return ( +
+ +
+ ); +}; + +export default PostFormView; \ No newline at end of file diff --git a/src/app/views/Posts/PostsView.js b/src/app/views/Posts/PostsView.js new file mode 100644 index 0000000..14a4d99 --- /dev/null +++ b/src/app/views/Posts/PostsView.js @@ -0,0 +1,16 @@ +/** + * @file View for displaying posts + */ + +import React from 'react'; +import PostsTable from '../../components/PostsTable'; + +const PostsView = () => { + return ( +
+ +
+ ); +}; + +export default PostsView; \ No newline at end of file diff --git a/src/app/views/VPN/VPN.jsx b/src/app/views/VPN/VPN.jsx index 010c784..44f48dd 100644 --- a/src/app/views/VPN/VPN.jsx +++ b/src/app/views/VPN/VPN.jsx @@ -38,6 +38,7 @@ const VPN = () => { setRevokeOpen( true ); }}> { + console.log( row.original ); setRevokeClientName( row.original.clientName ); setSuspendOpen( true ); }}> diff --git a/src/styles/App.css b/src/styles/App.css index 0a00b8f..cf0105d 100755 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -19,12 +19,3 @@ .App-link { color: #61dafb; } - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -}