WebAPP Exam 2025

This commit is contained in:
2025-06-28 18:31:48 +02:00
commit 0e9ec24d94
42 changed files with 7546 additions and 0 deletions

180
client/src/API.js Normal file
View File

@ -0,0 +1,180 @@
import dayjs from 'dayjs';
const SERVER_URL = 'http://localhost:3001/api/';
function getJson(httpResponsePromise) {
return new Promise((resolve, reject) => {
httpResponsePromise
.then((response) => {
if (response.ok) {
response.json()
.then( json => resolve(json) )
.catch( err => reject({ error: "Cannot parse server response" }))
} else {
response.json()
.then(obj =>
reject(obj)
)
.catch(err => reject({ error: "Cannot parse server response" }))
}
})
.catch(err =>
reject({ error: "Cannot communicate" })
) // connection error
});
}
const getPosts = async () => {
// Clientside publication is called timestamp
return getJson(
fetch(SERVER_URL + 'posts', { credentials: 'include' })
).then( json => {
const posts = json.map((post) => {
const clientPost = {
id: post.id,
title: post.title,
text: post.text,
authorid: post.authorid,
author: post.author,
maxcomments: post.maxcomments,
numcomments: post.numComments,
timestamp: post.publication,
comments: post.comments.map( c => { c.timestamp = c.publication; return c;})
}
//console.log(`POST: \n TITLE:${clientPost.title}\n ID:${clientPost.id}\n AUTHORID:${clientPost.authorid}\n AUTHOR:${clientPost.author}\n ${clientPost.timestamp} \n TEXT:${clientPost.text}`);
return clientPost;
});
return posts.sort((a,b) => dayjs(b.timestamp) - dayjs(a.timestamp));
})
}
function editComment(comment) {
const payload = { text: comment.text };
return getJson(
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
})
)
}
function setCommentInteresting(comment) {
return getJson(
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id + "/interest", {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
)
}
function unsetCommentInteresting(comment) {
return getJson(
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id + "/interest", {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
)
}
function addPost(post) {
return getJson(
fetch(SERVER_URL + "posts/", {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(post)
})
)
}
function addComment(postid, comment) {
return getJson(
fetch(SERVER_URL + "posts/" + postid + "/comments/", {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(comment)
})
)
}
function deletePost(post) {
return getJson(
fetch(SERVER_URL + "posts/" + post.id, {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
}
})
)
}
function deleteComment(comment) {
return getJson(
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id, {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
}
})
)
}
const totpVerify = async (totpCode) => {
return getJson(fetch(SERVER_URL + 'login-totp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({code: totpCode}),
})
)
};
const logIn = async (credentials) => {
return getJson(fetch(SERVER_URL + 'sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(credentials),
})
)
};
const getUserInfo = async () => {
return getJson(fetch(SERVER_URL + 'sessions/current', {
credentials: 'include'
})
)
};
const logOut = async() => {
return getJson(fetch(SERVER_URL + 'sessions/current', {
method: 'DELETE',
credentials: 'include'
})
)
}
const API = { getPosts, editComment, addPost, addComment, deletePost, deleteComment, setCommentInteresting, unsetCommentInteresting,
logIn, getUserInfo, logOut, totpVerify };
export default API;

0
client/src/App.css Normal file
View File

184
client/src/App.jsx Normal file
View File

@ -0,0 +1,184 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import { React, useState, useEffect } from 'react';
import { Container } from 'react-bootstrap';
import { Routes, Route, useLocation, Navigate, useNavigate } from 'react-router';
import './App.css';
import { GenericLayout, AddPostLayout, AddCommentLayout, EditLayout, LoginLayout, TotpLayout } from './components/Layout';
import API from './API.js';
function App() {
const navigate = useNavigate();
const [loggedIn, setLoggedIn] = useState(false);
const [user, setUser] = useState(null);
const [loggedInTotp, setLoggedInTotp] = useState(false);
const [postList, setPostList] = useState([]);
const [message, setMessage] = useState('');
const [dirty, setDirty] = useState(true);
const handleErrors = (err) => {
let msg = '';
if (err.error)
msg = err.error;
else if (err.errors) {
if (err.errors[0].msg)
msg = err.errors[0].msg + " : " + err.errors[0].path;
} else if (Array.isArray(err))
msg = err[0].msg + " : " + err[0].path;
else if (typeof err === "string") msg = String(err);
else msg = "Unknown Error";
setMessage(msg);
if (msg === 'Not authenticated')
setTimeout(() => { // do logout in the app state
setUser(null); setLoggedIn(false); setLoggedInTotp(false); setDirty(true);
}, 2000);
else
setTimeout(()=>setDirty(true), 2000);
}
const checkAuth = async() => {
try {
const user = await API.getUserInfo();
setLoggedIn(true);
setUser(user);
if (user.isTotp)
setLoggedInTotp(true);
} catch(err) {
}
};
/* This useeffect is called each time the location is changed, so each time a Cancel button goes back to
the home the posts are correctly fetched again. */
const location = useLocation();
useEffect( () => {
setDirty(true);
} ,[location, location.state]);
useEffect(()=> {
checkAuth();
}, []);
const handleLogin = async (credentials) => {
try {
const user = await API.logIn(credentials);
setUser(user);
setLoggedIn(true);
setDirty(true);
} catch (err) {
throw err;
}
};
const handleLogout = async () => {
await API.logOut();
setLoggedIn(false);
setLoggedInTotp(false);
setUser(null);
setDirty(true);
};
function deletePost(post) {
API.deletePost(post)
.then(()=> setDirty(true))
.catch(err=>handleErrors(err));
}
function deleteComment(comment) {
API.deleteComment(comment)
.then(()=> setDirty(true))
.catch(err=>handleErrors(err));
}
function editComment(comment) {
API.editComment(comment)
.then(()=>{setDirty(true); navigate('/');})
.catch(err=>handleErrors(err));
}
function setCommentInteresting(comment) {
setDirty(true);
API.setCommentInteresting(comment)
.then(()=>{setDirty(true); navigate('/');})
.catch(err=>handleErrors(err));
}
function unsetCommentInteresting(comment) {
API.unsetCommentInteresting(comment)
.then(()=>{setDirty(true); navigate('/');})
.catch(err=>handleErrors(err));
}
function addPost(post) {
API.addPost(post)
.then(()=>{setDirty(true); navigate('/');})
.catch(err=>handleErrors(err));
}
function addComment(postid, comment) {
API.addComment(postid,comment)
.then(()=>{setDirty(true); navigate('/');})
.catch(err=>handleErrors(err));
}
return (
<Container fluid className="bg-light">
<Routes>
<Route path="/" element={<GenericLayout
type={user != null ? user.type : "nouser"}
loggedIn={loggedIn} loggedInTotp={loggedInTotp}
message={message} setMessage={setMessage}
user={user} logout={handleLogout}
checkAuth={checkAuth}
postList={postList} setPostList={setPostList}
deletePost={deletePost} deleteComment={deleteComment} editComment={editComment}
disableAdd={!loggedIn} disableActions={!loggedInTotp}
handleErrors={handleErrors}
setCommentInteresting={setCommentInteresting} unsetCommentInteresting={unsetCommentInteresting}
dirty={dirty} setDirty={setDirty} /> }>
<Route path="add">
<Route path="post" element={loggedIn?
<AddPostLayout setDirty={setDirty} addPost={addPost} />
: <Navigate replate to='/'/>} />
<Route path=":postid/comment" element={<AddCommentLayout
setDirty={setDirty}
addComment={addComment} />} />
</Route>
<Route path="edit/:commentid" element={loggedIn?
<EditLayout setDirty={setDirty}
posts={postList}
editComment={editComment} /> :
<Navigate replace to='/' />} />
</Route>
<Route path='/login' element={ <LoginWithTotp loggedIn={loggedIn} login={handleLogin}
checkAuth={checkAuth}
user={user} setLoggedInTotp={setLoggedInTotp} /> } />
</Routes>
</Container>
);
}
function LoginWithTotp(props) {
if (props.loggedIn) {
if (props.user && props.user.type == "administrator") {
if (props.loggedInTotp) {
return <Navigate replace to='/' />;
} else {
return <TotpLayout totpSuccessful={() => props.setLoggedInTotp(true)} />;
}
} else {
return <Navigate replace to='/' />;
}
} else {
return <LoginLayout checkAuth={props.checkAuth} login={props.login} />;
}
}
export default App

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,139 @@
import { useState } from 'react';
import { Form, Button, Alert, Col, Row } from 'react-bootstrap';
import { useNavigate } from 'react-router';
import API from '../API.js'
function TotpForm(props) {
const [totpCode, setTotpCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const navigate = useNavigate();
const doTotpVerify = () => {
API.totpVerify(totpCode)
.then(() => {
setErrorMessage('');
props.totpSuccessful();
navigate('/');
})
.catch(() => {
setErrorMessage('Something went wrong, please try again');
})
}
const handleSubmit = (event) => {
event.preventDefault();
setErrorMessage('');
let valid = true;
if(totpCode === '' || totpCode.length !== 6)
valid = false;
if (valid) {
doTotpVerify(totpCode);
} else {
setErrorMessage('Invalid content in form: either empty or not 6-char long');
}
};
return (
<Row>
<Col xs={4}></Col>
<Col xs={4}>
<h2>Second Factor Authentication</h2>
<h5>Please enter the code that you read on your device</h5>
<Form onSubmit={handleSubmit}>
{errorMessage ? <Alert variant='danger' dismissible onClick={() => setErrorMessage('')}>{errorMessage}</Alert> : ''}
<Form.Group controlId='totpCode'>
<Form.Label>Code</Form.Label>
<Form.Control type='text' required={true} value={totpCode} onChange={ev => setTotpCode(ev.target.value)} />
</Form.Group>
<Button className='my-2' type='submit'>Validate</Button>
<Button className='my-2 mx-2' variant='danger' onClick={() => navigate('/')}>Cancel</Button>
</Form>
</Col>
<Col xs={4}></Col>
</Row>
)
}
function LoginForm(props) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [istotp, setIstotp] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const navigate = useNavigate();
const handleSubmit = (event) => {
event.preventDefault();
const credentials = { username, password };
if (!username) {
setErrorMessage('Username cannot be empty');
} else if (!password) {
setErrorMessage('Password cannot be empty');
} else {
props.login(credentials)
.catch((err) => {
setErrorMessage(err.error);
});
}
};
return (
<Row>
<Col xs={4}></Col>
<Col xs={4}>
<h1 className="pb-3">Login</h1>
<Form onSubmit={handleSubmit}>
{errorMessage? <Alert dismissible onClose={() => setErrorMessage('')} variant="danger">{errorMessage}</Alert> : null}
<Form.Group className="mb-3">
<Form.Label>Username</Form.Label>
<Form.Control
type="username"
value={username} placeholder="Enter your username"
onChange={(ev) => setUsername(ev.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Password</Form.Label>
<Form.Control
type="password"
value={password} placeholder="Enter your password"
onChange={(ev) => setPassword(ev.target.value)}
/>
</Form.Group>
<Form.Check type="checkbox"
label="Use OTP"
onChange={(ev) => setIstotp(ev.target.value)}
/>
<Button className="my-2" type="submit" onClick={()=> istotp ? props.checkAuth() : navigate('/')} >Login</Button>
<Button className='my-2 mx-2' variant='danger' onClick={() => navigate('/')}>Cancel</Button>
</Form>
</Col>
<Col xs={4}></Col>
</Row>
)
};
function LogoutButton(props) {
return (
<Button variant="outline-light" onClick={props.logout}>Logout</Button>
)
}
function LoginButton(props) {
const navigate = useNavigate();
return (
<Button variant="outline-light" onClick={()=> navigate('/login')}>{props.text}</Button>
)
}
export { LoginForm, LogoutButton, LoginButton, TotpForm };

View File

@ -0,0 +1,63 @@
import {useState} from 'react';
import {Form, Button, Alert} from 'react-bootstrap';
import { Link, useParams } from 'react-router';
/* Edit or add a comment */
const CommentForm = (props) => {
const [text, setText] = useState(props.commentToEdit ? props.commentToEdit.text : '');
const [errorMsg, setErrorMsg] = useState('');
const postid = useParams().postid;
const handleSubmit = (event) => {
event.preventDefault();
// String.trim() method is used for removing leading and ending whitespaces from the title.
const comment = { "text" : text.trim() };
if(comment.text.length == 0){
setErrorMsg('New text tength cannot be 0');
}
//Probably already checked by the form
else if ( typeof comment.text !== "string" ){
setErrorMsg("Text comment must be a string");
}
else {
/* To edit we dont need postid since comment id is unique in db, instead to add a new comment to a post we need it. */
if (props.commentToEdit) {
comment.id = props.commentToEdit.id;
comment.postid = props.commentToEdit.postid;
props.editComment(comment);
//navigate('/'); // not here, we must wait for the editing on the server to be finished
} else {
props.addComment(postid, comment);
}
}
}
return (
<>
{ errorMsg ?
<Alert variant='danger' onClose={()=>setErrorMsg('')} dismissible>{errorMsg}</Alert> :
false }
{props.commentToEdit ?
<h1>Edit comment</h1> :
<h1> Insert new comment</h1>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Text</Form.Label>
<Form.Control as="textarea" required={true} value={text} onChange={event => setText(event.target.value)}/>
</Form.Group>
<Button className="mb-3" variant="primary" type="submit">Save</Button>
&nbsp;
<Link to={"/"} >
<Button className="mb-3" variant="danger">Cancel</Button>
</Link>
</Form>
</>
)
}
export { CommentForm };

View File

@ -0,0 +1,119 @@
import 'dayjs';
import { Button, Card} from 'react-bootstrap';
import { useNavigate} from 'react-router';
function ForumTable(props) {
const { posts } = props;
/*Posts embedded also comments*/
return(
<div>
{posts.map((post) => <Post loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
user={props.user}
postData={post} key={post.title}
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
deletePost={props.deletePost} deleteComment={props.deleteComment}
editComment={props.editComment}/>)}
</div>
);
}
function Post(props) {
const navigate = useNavigate();
/* If is logged OTP or Owner then is always enabled */
/* Somehow can happen if reloading the page in other form that loggedIn remains true without a user state */
const isAuthor = props.loggedIn && props.user ?
(props.user.id == props.postData.authorid ? true : false)
: false;
return (
<>
<Card>
<Card.Header className="bg-primary text-white">{props.postData.title}</Card.Header>
<Card.Body>
<Card.Text style={{ whiteSpace: 'pre-wrap' }}>
{props.postData.text}
</Card.Text>
{isAuthor || props.loggedInTotp ?
<Button className="mx-1 mt-1" variant='danger'
onClick={() => { props.deletePost(props.postData) }} >
<i className='bi bi-trash'></i>
</Button> :
null
}
<Button variant="primary" className="mx-1 mt-1" disabled={props.postData.maxcomments != -1 && props.postData.numcomments == props.postData.maxcomments ? true : false}
onClick={()=>{navigate(`/add/${props.postData.id}/comment`); window.scrollTo(0,0);}} >
<i className='bi bi-plus'/>
</Button>
</Card.Body>
<Card.Footer>
Published by: {props.postData.author} on {props.postData.timestamp}<br/>
<b className="mx-1">Comments: {props.postData.numcomments} </b>
{props.postData.maxcomments != -1 ? <b> Max:{props.postData.maxcomments} </b> : null}
</Card.Footer>
</Card>
<br/>
<div>{props.postData.comments.map( (comment) => <Comment loggedInTotp={props.loggedInTotp} user={props.user} loggedIn={props.loggedIn}
commentData={comment} key={comment.id}
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
deleteComment={props.deleteComment} editComment={props.editComment}/>)}</div>
<br/>
</>
);}
function Comment(props) {
const navigate = useNavigate();
/* Authorid is always defined (at least is null) instead author name is undefined if authorid is null
One user can delete the comment if logged with totp or if OWNER of comment */
function checkAuthor(user, comment){
if(user){
return user.id == comment.authorid;
}
return false;
}
const isAuthor = props.loggedIn ? checkAuthor(props.user,props.commentData) : false;
const likedClass = "mx-5 my-3 bg-light border border-3 border-success rounded"
const generalClass = "mx-5 my-3 bg-light border border-3 rounded"
return (
<>
<Card className={props.commentData.isInterested ? likedClass : generalClass}>
<Card.Header>
<b className="mx-2 text-primary"> {props.commentData.authorid == null ? "Anonymous" : props.commentData.author }</b>
{ props.commentData.isInterested ?
<i className="bi bi-flag-fill"></i>
: <br/> }
</Card.Header>
<Card.Body>
<Card.Text style={{ whiteSpace: 'pre-wrap' }}>
{props.commentData.text}
</Card.Text>
{ props.loggedIn ?
(props.commentData.isInterested ?
<Button className="mx-1" onClick={ () => props.unsetCommentInteresting(props.commentData) }>
<i className={"bi bi-hand-thumbs-up-fill"}></i>
</Button> :
<Button className="mx-1" onClick={ () => props.setCommentInteresting(props.commentData) } >
<i className={"bi bi-hand-thumbs-up"}></i>
</Button>)
:
null }
{ isAuthor || props.loggedInTotp ? <>
<Button className="mx-1" variant='danger'
onClick={ () => props.deleteComment(props.commentData) } >
<i className='bi bi-trash'></i>
</Button>
<Button className="mx-1" variant='warning'
onClick={() => { navigate(`/edit/${props.commentData.id}`); window.scrollTo(0,0); }} >
<i className='bi bi-pencil'></i>
</Button> </> : null }
</Card.Body>
<Card.Footer>
Published on {props.commentData.timestamp} <br/>
{props.commentData.numInterested ?
<b>{props.commentData.numInterested } interested </b> :
<br/> }
</Card.Footer>
</Card>
</>
);
}
export { ForumTable };

View File

@ -0,0 +1,146 @@
import { Row, Col, Button, Spinner, Alert } from 'react-bootstrap';
import { Outlet, Link, useParams, Navigate, useNavigate } from 'react-router';
import { Navigation } from './Navigation';
import { ForumTable } from './Forum';
import { CommentForm } from './CommentEdit';
import { PostForm } from './PostCreate';
import { useEffect, useState } from 'react';
import { LoginForm, TotpForm } from './Auth';
import API from '../API.js';
function NotFoundLayout(props) {
return (
<>
<h2>This route is not valid!</h2>
<Link to="/">
<Button variant="primary">Go back to the main page!</Button>
</Link>
</>
);
}
function LoginLayout(props) {
return (
<Row>
<Col>
<LoginForm checkAuth={props.checkAuth} login={props.login} />
</Col>
</Row>
);
}
function TotpLayout(props) {
return (
<Row>
<Col>
<TotpForm totpSuccessful={props.totpSuccessful} />
</Col>
</Row>
);
}
function AddPostLayout(props) {
return (
<PostForm setDirty={props.setDirty} addPost={props.addPost} />
);
}
function AddCommentLayout(props) {
return (
<CommentForm setDirty={props.setDirty} addComment={props.addComment} />
);
}
function EditLayout(props) {
const { commentid } = useParams();
const findComment = (posts) => {
for (const post of posts){
if( post.comments )
for( const comment of post.comments ){
if(comment.id == commentid)
return comment;
}
}
}
const commentToEdit = findComment(props.posts)
console.log("EDIT COMMENT: "+commentToEdit);
return(
<>
{commentToEdit?
<CommentForm setDirty={props.setDirty} editComment={props.editComment} commentToEdit={commentToEdit} />
: <Navigate to={"/"} />}
</>
);
}
function TableLayout(props) {
const navigate = useNavigate();
const [waiting, setWaiting] = useState(true);
useEffect( () => {
if(props.dirty){
API.getPosts()
.then( posts => {
props.setPostList(posts);
props.setDirty(false);
setWaiting(false);
}).catch( e => { props.handleErrors(e); } );
props.setDirty(false);
}
},[props.dirty]);
return (
<>
{ props.loggedIn ?
<div className="d-flex flex-row justify-content-between">
<Button variant="primary" className="my-2" disabled={props.disableAdd}
onClick={()=>navigate('/add/post')}>&#43;</Button>
</div> :
<Row><Col> <br/> </Col></Row> }
{ waiting? <Spinner /> :
<ForumTable
loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
posts={props.postList} deletePost={props.deletePost}
deleteComment={props.deleteComment} editComment={props.editComment}
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
user = {props.user} checkAuth = {props.checkAuth}
disableActions={props.disableActions} />
}
</>
);
}
function GenericLayout(props) {
return (
<>
<Row>
<Col>
<Navigation type={props.type} checkAuth={props.checkAuth} loggedIn={props.loggedIn} user={props.user} loggedInTotp={props.loggedInTotp} logout={props.logout} />
</Col>
</Row>
<Row>
<Col>
{props.message? <Alert className='my-1' onClose={() => props.setMessage('')} variant='danger' dismissible>
{props.message}</Alert> : null}
</Col>
</Row>
<Row>
<Outlet />
<TableLayout
loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
user={props.user} checkAuth={props.checkAuth}
message={props.message} setMessage={props.setMessage}
postList={props.postList} setPostList={props.setPostList}
deletePost={props.deletePost} deleteComment={props.deleteComment} editComment={props.editComment}
disableAdd={!props.loggedIn} disableActions={!props.loggedInTotp}
handleErrors={props.handleErrors}
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
dirty={props.dirty} setDirty={props.setDirty} />
</Row>
</>
);
}
export { GenericLayout, NotFoundLayout, TableLayout, AddPostLayout, AddCommentLayout, EditLayout, LoginLayout, TotpLayout };

View File

@ -0,0 +1,35 @@
import 'bootstrap-icons/font/bootstrap-icons.css';
import { Navbar, Nav, Form } from 'react-bootstrap';
import { Link } from 'react-router';
import { LoginButton, LogoutButton } from './Auth';
const spawnButton = (props) => {
if(props.loggedIn){
if(props.loggedInTotp)
return <> <LogoutButton logout={props.logout}/> </>
if(props.type == "administrator")
return <> <LogoutButton logout={props.logout}/> <LoginButton text="OTP"/> </>
return <> <LogoutButton logout={props.logout}/> </>
}
return <LoginButton text="Login"/>
}
const Navigation = (props) => {
return (
<Navbar bg="primary" expand="md" variant="dark" className="rounded navbar-padding">
<Navbar.Brand as={Link} to="/" >
<i className="bi bi-flask mx-2" />
Forum Exam
</Navbar.Brand>
<Nav className="ms-auto">
<Navbar.Text className="mx-2 fs-5">
{props.user == undefined ? 'Anonymous' : (props.user.username && `Logged in ${props.loggedInTotp ? '(2FA)' : ''} as: ${props.user.username}`)}
</Navbar.Text>
<Form className="mx-2 mt-1">
{spawnButton(props)}
</Form>
</Nav>
</Navbar>
);
}
export { Navigation };

View File

@ -0,0 +1,63 @@
import { useState } from 'react';
import { Form, Button, Alert } from 'react-bootstrap';
import { Link } from 'react-router';
const PostForm = (props) => {
const [title, setTitle] = useState('');
const [text, setText] = useState('');
const [checked, setChecked] = useState(false)
/* Default is no limit on comments */
const [max, setMax] = useState(0);
const [errorMsg, setErrorMsg] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
const post = { "title": title.trim(), "text": text.trim(), max: checked == true ? max : -1};
if (post.title.length == 0) {
setErrorMsg('Title length cannot be 0');
} else if (post.text.length == 0){
setErrorMsg('Text of post length cannot be 0');
} else if (post.max < -1) {
setErrorMsg('Invalid value for max number of comments');
} else {
props.addPost(post);
}
}
return (
<>
{errorMsg? <Alert variant='danger' onClose={()=>setErrorMsg('')} dismissible>{errorMsg}</Alert> : false }
<h1>Create a new post</h1>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Title</Form.Label>
<Form.Control type="text" required={true} value={title} onChange={event => setTitle(event.target.value)}/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Text</Form.Label>
<Form.Control as="textarea" required={true} value={text} onChange={event => setText(event.target.value)}/>
</Form.Group>
<Form.Group className="mb-3"> <Form.Check type="checkbox"
label="Set Max Comments"
onChange={(ev) => setChecked(ev.target.checked)}
/>
<Form.Label>Max Comments</Form.Label>
<Form.Control type="number" min={0} step={1} value={max} disabled={!checked} onChange={event => setMax(parseInt(event.target.value))}/>
</Form.Group>
<Button className="mb-3" variant="primary" type="submit">Save</Button>
&nbsp;
<Link to={"/"} >
<Button className="mb-3" variant="danger">Cancel</Button>
</Link>
</Form>
</>
)
}
export { PostForm };

0
client/src/index.css Normal file
View File

13
client/src/main.jsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { BrowserRouter } from 'react-router';
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)