Merge pull request 'Front-end panel' (#1) from admin into main
Some checks are pending
CI/CD / build-and-test (push) Waiting to run
Some checks are pending
CI/CD / build-and-test (push) Waiting to run
Reviewed-on: #1
This commit is contained in:
commit
93be26fac8
26 changed files with 5047 additions and 7 deletions
13
README.md
13
README.md
|
@ -17,7 +17,7 @@
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
<p align="center">A simple yet somehow overdesigned guestbook system featuring a simple control panel <small>(which is a WIP so you'll have to make do with a db manager)</small></p>
|
<p align="center">A simple yet somehow overdesigned guestbook system featuring a simple control panel</p>
|
||||||
|
|
||||||
<p align="center"> This is phase I of the personal backscratchers project.</p>
|
<p align="center"> This is phase I of the personal backscratchers project.</p>
|
||||||
|
|
||||||
|
@ -29,6 +29,8 @@
|
||||||
- [📑 Documentation ](#-documentation-)
|
- [📑 Documentation ](#-documentation-)
|
||||||
- [🏁 Getting Started ](#-getting-started-)
|
- [🏁 Getting Started ](#-getting-started-)
|
||||||
- [🕸️ Prerequisites](#️-prerequisites)
|
- [🕸️ Prerequisites](#️-prerequisites)
|
||||||
|
- [Backend:](#backend)
|
||||||
|
- [Admin panel:](#admin-panel)
|
||||||
- [🚀 Deployment ](#-deployment-)
|
- [🚀 Deployment ](#-deployment-)
|
||||||
- [⛏️ Built Using ](#️-built-using-)
|
- [⛏️ Built Using ](#️-built-using-)
|
||||||
- [✍️ Authors ](#️-authors-)
|
- [✍️ Authors ](#️-authors-)
|
||||||
|
@ -49,6 +51,8 @@ These instructions will get you a copy of the project up and running on your loc
|
||||||
|
|
||||||
## 🕸️ Prerequisites
|
## 🕸️ Prerequisites
|
||||||
|
|
||||||
|
### Backend:
|
||||||
|
|
||||||
For running it locally:
|
For running it locally:
|
||||||
- .NET 8.0
|
- .NET 8.0
|
||||||
- A running instance of MongoDB
|
- A running instance of MongoDB
|
||||||
|
@ -84,6 +88,12 @@ You will be able to see in `build/docker-compose.public.yml` that the applicatio
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> For local usage of the backend, you can use `docker-compose.local.yml` and edit the fields you need.
|
> For local usage of the backend, you can use `docker-compose.local.yml` and edit the fields you need.
|
||||||
|
|
||||||
|
### Admin panel:
|
||||||
|
|
||||||
|
The admin panel is a simple React/Vite app. For development, it should be enough to run `vite` in Guestbooky-admin's `src` folder.
|
||||||
|
|
||||||
|
In order to create a live version, adjust the **API_URL** path in `Guestbooky-admin/src/environment/constants.js`, and execute `vite build`.
|
||||||
|
|
||||||
## 🚀 Deployment <a name = "deployment"></a>
|
## 🚀 Deployment <a name = "deployment"></a>
|
||||||
|
|
||||||
Use `docker-compose.public.yml` as a basis. it should create the image for you and start running.
|
Use `docker-compose.public.yml` as a basis. it should create the image for you and start running.
|
||||||
|
@ -92,6 +102,7 @@ Use `docker-compose.public.yml` as a basis. it should create the image for you a
|
||||||
|
|
||||||
- [MongoDB](https://www.mongodb.com/) - Database
|
- [MongoDB](https://www.mongodb.com/) - Database
|
||||||
- [.NET](https://dot.net/) - Backend
|
- [.NET](https://dot.net/) - Backend
|
||||||
|
- [React](https://react.dev/)/[Vite](https://vite.dev) - Admin Panel
|
||||||
- [Cloudflare Turnstile](https://www.cloudflare.com/pt-br/products/turnstile/) - Captcha
|
- [Cloudflare Turnstile](https://www.cloudflare.com/pt-br/products/turnstile/) - Captcha
|
||||||
|
|
||||||
## ✍️ Authors <a name = "authors"></a>
|
## ✍️ Authors <a name = "authors"></a>
|
||||||
|
|
|
@ -43,3 +43,4 @@ ___
|
||||||
- There isn't much exception handling, except in the API layer. This is on purpose. Another thing that this project could really use, but is left as an exercise, is using a `Maybe<T>/Result<T>/ErrorOr<T>` type.
|
- There isn't much exception handling, except in the API layer. This is on purpose. Another thing that this project could really use, but is left as an exercise, is using a `Maybe<T>/Result<T>/ErrorOr<T>` type.
|
||||||
- Since there is so little that can go wrong with *low-stakes CRUDding*, it is a reasonable trade-off to let the API layer catch and send an internal server error.
|
- Since there is so little that can go wrong with *low-stakes CRUDding*, it is a reasonable trade-off to let the API layer catch and send an internal server error.
|
||||||
|
|
||||||
|
- By default, you need to choose between good Cookie-based authentication defaults or REST-friendly authentication via the `Authorization` header. Luckily you can support both with a few small additions - it made more sense to keep *RESTy* as the main method and stick Cookie support to its tail.
|
24
src/Guestbooky-admin/.gitignore
vendored
Normal file
24
src/Guestbooky-admin/.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
38
src/Guestbooky-admin/eslint.config.js
Normal file
38
src/Guestbooky-admin/eslint.config.js
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: { react: { version: '18.3' } },
|
||||||
|
plugins: {
|
||||||
|
react,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...react.configs['jsx-runtime'].rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react/jsx-no-target-blank': 'off',
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
13
src/Guestbooky-admin/index.html
Normal file
13
src/Guestbooky-admin/index.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/src/assets/guestbooky.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Guestbooky Admin Panel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
4391
src/Guestbooky-admin/package-lock.json
generated
Normal file
4391
src/Guestbooky-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
src/Guestbooky-admin/package.json
Normal file
30
src/Guestbooky-admin/package.json
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "guestbooky-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"guestbooky-admin": "file:",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.13.0",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
"eslint": "^9.13.0",
|
||||||
|
"eslint-plugin-react": "^7.37.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.13",
|
||||||
|
"globals": "^15.11.0",
|
||||||
|
"vite": "^5.4.9"
|
||||||
|
}
|
||||||
|
}
|
24
src/Guestbooky-admin/src/App.css
Normal file
24
src/Guestbooky-admin/src/App.css
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
.header{
|
||||||
|
font-size: 3em;
|
||||||
|
word-spacing: 0.3em;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
background: linear-gradient(180deg, hsl(15, 80%, 10%) 0%, hsl(15, 50%, 60%) 100%);
|
||||||
|
width: 100%;
|
||||||
|
padding: 2ch;
|
||||||
|
border-bottom: 0.1em solid hsl(15, 80%, 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin-inline: auto;
|
||||||
|
width: 1200px;
|
||||||
|
color: hsl(15, 100%, 90%);
|
||||||
|
text-shadow: 0.01em 0.01em 0.01em hsl(15, 0%, 20%);
|
||||||
|
font-family: "Carattere", cursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 1200px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 2ch;
|
||||||
|
}
|
24
src/Guestbooky-admin/src/App.jsx
Normal file
24
src/Guestbooky-admin/src/App.jsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import {useState} from 'react'
|
||||||
|
import AdminPage from './components/AdminPage'
|
||||||
|
import LoginPage from './components/LoginPage'
|
||||||
|
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
||||||
|
|
||||||
|
const handleLoggedIn = (newValue) => {
|
||||||
|
setIsLoggedIn(newValue)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="header"><h1>Guestbooky Admin</h1></div>
|
||||||
|
<div className="content">
|
||||||
|
{isLoggedIn ? <AdminPage className="content"/> :
|
||||||
|
<LoginPage onLoggedIn={handleLoggedIn} className="content"/>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
BIN
src/Guestbooky-admin/src/assets/guestbooky.png
Normal file
BIN
src/Guestbooky-admin/src/assets/guestbooky.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
111
src/Guestbooky-admin/src/components/AdminPage.css
Normal file
111
src/Guestbooky-admin/src/components/AdminPage.css
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
@keyframes smallFadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
animation: smallFadeIn 0.5s ease-out forwards;
|
||||||
|
animation-delay: calc(0.2s * var(--child-index));
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning{
|
||||||
|
background-color: hsla(15, 100%, 90%, 0.75);
|
||||||
|
border: 0.2em solid hsla(15, 80%, 70%, 1);
|
||||||
|
border-radius: 0.3em;
|
||||||
|
padding: 2ch 1ch;
|
||||||
|
width: fit-content;
|
||||||
|
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation button {
|
||||||
|
font-size: 1em;
|
||||||
|
margin: 2em;
|
||||||
|
background: hsla(15, 60%, 80%, 1);
|
||||||
|
border-radius: 0.2em;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.8em 0.5em;
|
||||||
|
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation button:hover {
|
||||||
|
background: hsla(15, 80%, 90%, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li{
|
||||||
|
line-height: 1.6em;
|
||||||
|
display: grid;
|
||||||
|
grid-row-gap: 2em;
|
||||||
|
padding: 1em;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
font-family: 'Solway', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li h2{
|
||||||
|
align-self: center;
|
||||||
|
grid-row: 1;
|
||||||
|
background: linear-gradient(to right, hsl(15, 40%, 85%), hsla(15, 40%, 85%, 0));
|
||||||
|
border-radius: 0.2em;
|
||||||
|
padding: 0.5em 0 0.5em 0.5em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li h3{
|
||||||
|
align-self: center;
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: right;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
vertical-align: bottom;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ul li button{
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: right;
|
||||||
|
width: fit-content;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
padding: 0.5em;
|
||||||
|
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||||
|
background: hsla(15, 60%, 80%, 0.75);
|
||||||
|
}
|
||||||
|
ul li button:hover {
|
||||||
|
background: hsla(15, 80%, 90%, 0.75);
|
||||||
|
}
|
||||||
|
.message-text {
|
||||||
|
grid-row: 2;
|
||||||
|
grid-column: 1 / 4;
|
||||||
|
background-color: hsl(15, 50%, 90%);
|
||||||
|
margin: 1em 0.5em;
|
||||||
|
padding: 1em 0.5em;
|
||||||
|
font-weight: 300;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
box-shadow: 0.05em 0.05em 0.05em hsla(15, 20%, 20%, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text p{
|
||||||
|
line-height: 1.5em;
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
|
text-align-last: left;
|
||||||
|
}
|
51
src/Guestbooky-admin/src/components/AdminPage.jsx
Normal file
51
src/Guestbooky-admin/src/components/AdminPage.jsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import useMessages from "../hooks/useMessages.js";
|
||||||
|
|
||||||
|
import './AdminPage.css'
|
||||||
|
|
||||||
|
|
||||||
|
const AdminPage = () => {
|
||||||
|
const {messages, totalMessages, loading, error, removeMessage, page, nextPage, previousPage} = useMessages();
|
||||||
|
const localeOptions = {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
second: 'numeric',
|
||||||
|
timeZoneName: 'short'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (<div className='warning'><p>Loading...</p></div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0 && page > 1) {
|
||||||
|
previousPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (<div className='warning'><p>No messages.</p></div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='warning'><p>{error ? 'Error : ' + error : 'Total messages: ' + totalMessages }</p></div>
|
||||||
|
<ul>
|
||||||
|
{messages.length > 0 ? messages.map((message, index) => (
|
||||||
|
<li key={message.id} className='list-item' style={{ '--child-index': index}} >
|
||||||
|
<h2>{message.author}</h2>
|
||||||
|
<h3>{new Date(message.timestamp).toLocaleString(navigator.language, localeOptions)}</h3>
|
||||||
|
<button onClick={() => removeMessage(message.id)}>Delete</button>
|
||||||
|
<div className='message-text'><p>{message.message}</p></div>
|
||||||
|
</li>
|
||||||
|
)) : ''}
|
||||||
|
</ul>
|
||||||
|
<div className='navigation'>
|
||||||
|
<button onClick={() => previousPage()}>Previous Page</button>
|
||||||
|
<button onClick={() => nextPage()}>Next Page</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default AdminPage;
|
64
src/Guestbooky-admin/src/components/LoginPage.css
Normal file
64
src/Guestbooky-admin/src/components/LoginPage.css
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
.login-form{
|
||||||
|
display: flex;
|
||||||
|
margin-inline: auto;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
vertical-align: center;
|
||||||
|
margin-top: 20vh;
|
||||||
|
background-color: hsla(15, 100%, 93%);
|
||||||
|
width: fit-content;
|
||||||
|
padding: 2em;
|
||||||
|
border-radius: 1em;
|
||||||
|
box-shadow: 0.05em 0.05em 0.05em hsla(15, 20%, 20%, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in{
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10vh);
|
||||||
|
animation: fadeIn 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20vh);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 20ch;
|
||||||
|
grid-column-gap: 2ch;
|
||||||
|
grid-row-gap: 2ch;
|
||||||
|
align-items: center;
|
||||||
|
font-family: 'Solway', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
form label {
|
||||||
|
font-size: 1.4em;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
form input[type=text], form input[type=password] {
|
||||||
|
font-size: 1.4em;
|
||||||
|
border: 0.2em hsl(15, 100%, 50%) outset;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
padding: 0.2em 0.2em;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
form button {
|
||||||
|
margin-top: 1em;
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
padding: 1em 2em;
|
||||||
|
font-size: 1.4em;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
background-color: hsl(15, 50%, 85%);
|
||||||
|
font-family: 'Solway', serif;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
40
src/Guestbooky-admin/src/components/LoginPage.jsx
Normal file
40
src/Guestbooky-admin/src/components/LoginPage.jsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import useAuth from '../hooks/useAuth.js';
|
||||||
|
|
||||||
|
import './LoginPage.css'
|
||||||
|
|
||||||
|
const LoginPage = ({onLoggedIn}) =>
|
||||||
|
{
|
||||||
|
const userInputRef = useRef(null);
|
||||||
|
const passInputRef = useRef(null);
|
||||||
|
const { authenticate, error } = useAuth();
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const user = userInputRef.current.value;
|
||||||
|
const pass = passInputRef.current.value;
|
||||||
|
|
||||||
|
authenticate({user, pass})
|
||||||
|
.then(() => {
|
||||||
|
if (error === null) {
|
||||||
|
onLoggedIn(true)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return(
|
||||||
|
<>
|
||||||
|
<div className="login-form fade-in">
|
||||||
|
<form action="post" onSubmit={handleSubmit}>
|
||||||
|
<label>User: </label><input type='text' name='username' placeholder=''
|
||||||
|
ref={userInputRef}></input>
|
||||||
|
<label>Password: </label><input type='password' name='password' placeholder=''
|
||||||
|
ref={passInputRef}></input>
|
||||||
|
<button type='submit' className='login-button'>Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
1
src/Guestbooky-admin/src/environment/constants.js
Normal file
1
src/Guestbooky-admin/src/environment/constants.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const API_URL = 'http://localhost:8080';
|
19
src/Guestbooky-admin/src/hooks/useAuth.js
Normal file
19
src/Guestbooky-admin/src/hooks/useAuth.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { post } from '../services/httpService.js'
|
||||||
|
|
||||||
|
const useAuth = () => {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const authenticate = async ({user, pass}) => {
|
||||||
|
try {
|
||||||
|
await post('/auth/login', {username: user, password: pass});
|
||||||
|
}
|
||||||
|
catch(err){
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticate, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAuth;
|
67
src/Guestbooky-admin/src/hooks/useMessages.js
Normal file
67
src/Guestbooky-admin/src/hooks/useMessages.js
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { get, del } from '../services/httpService.js'
|
||||||
|
|
||||||
|
const useMessages = () => {
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [totalMessages, setTotalMessages] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const amountPerPage = 10;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
try{
|
||||||
|
setLoading(true);
|
||||||
|
const data = await get('/message', {
|
||||||
|
headers: {
|
||||||
|
'Range': 'messages=' + ((page - 1) * amountPerPage) + '-' + (((page - 1) * amountPerPage) + amountPerPage),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, rangeInfo] = data.headers['content-range'].split(' ');
|
||||||
|
const [, total] = rangeInfo.split('/');
|
||||||
|
|
||||||
|
setTotalMessages(parseInt(total));
|
||||||
|
setMessages(data.data);
|
||||||
|
} catch(err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchMessages();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const removeMessage = async (id) => {
|
||||||
|
try{
|
||||||
|
await del('/message', {id: id});
|
||||||
|
setMessages((prevlist) => prevlist.filter(message => message.id !== id));
|
||||||
|
setTotalMessages(totalMessages - 1)
|
||||||
|
}catch(err){
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if(page * amountPerPage < totalMessages)
|
||||||
|
setPage((page + 1));
|
||||||
|
}
|
||||||
|
const previousPage = () => {
|
||||||
|
if (page > 1)
|
||||||
|
setPage((page - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
totalMessages,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
removeMessage,
|
||||||
|
page,
|
||||||
|
nextPage,
|
||||||
|
previousPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useMessages;
|
40
src/Guestbooky-admin/src/index.css
Normal file
40
src/Guestbooky-admin/src/index.css
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
@import url(https://fonts.bunny.net/css?family=carattere:400|solway:300,500);
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce){
|
||||||
|
html:focus-within {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition: none;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after{
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: all 0.05s ease;
|
||||||
|
animation-duration: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:focus-within{
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
img, picture, svg, video, canvas{
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(90deg, hsl(15, 80%, 90%) 0%, hsl(15, 80%, 98%) 5%, hsl(15, 80%, 98%) 95%, hsl(15, 80%, 90%) 100%);
|
||||||
|
color: hsl(15, 70%, 5%);
|
||||||
|
}
|
7
src/Guestbooky-admin/src/main.jsx
Normal file
7
src/Guestbooky-admin/src/main.jsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<App />
|
||||||
|
)
|
21
src/Guestbooky-admin/src/services/httpService.js
Normal file
21
src/Guestbooky-admin/src/services/httpService.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { API_URL } from '../environment/constants.js'
|
||||||
|
|
||||||
|
const httpClient = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
withCredentials: true,
|
||||||
|
timeout: 5000,
|
||||||
|
headers: {'Content-Type': 'application/json',}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const get = (endpoint, params) => {
|
||||||
|
return httpClient.get(endpoint, { headers: params.headers, data: params.data});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const post = (endpoint, data) => {
|
||||||
|
return httpClient.post(endpoint, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const del = (endpoint, data) => {
|
||||||
|
return httpClient.delete(endpoint, {data: data });
|
||||||
|
}
|
7
src/Guestbooky-admin/vite.config.js
Normal file
7
src/Guestbooky-admin/vite.config.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Guestbooky.API.Enums;
|
||||||
|
|
||||||
|
namespace Guestbooky.API.Configurations;
|
||||||
|
|
||||||
|
public class APISettings
|
||||||
|
{
|
||||||
|
public required RunningEnvironment RunningEnvironment { get; set; }
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
using Guestbooky.API.DTOs.Auth;
|
using Guestbooky.API.Configurations;
|
||||||
|
using Guestbooky.API.DTOs.Auth;
|
||||||
using Guestbooky.Application.UseCases.AuthenticateUser;
|
using Guestbooky.Application.UseCases.AuthenticateUser;
|
||||||
using Guestbooky.Application.UseCases.RefreshToken;
|
using Guestbooky.Application.UseCases.RefreshToken;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
@ -12,11 +13,13 @@ public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
private readonly ILogger<AuthController> _logger;
|
private readonly ILogger<AuthController> _logger;
|
||||||
|
private readonly APISettings _apiSettings;
|
||||||
|
|
||||||
public AuthController(IMediator mediator, ILogger<AuthController> logger)
|
public AuthController(IMediator mediator, ILogger<AuthController> logger, APISettings apiSettings)
|
||||||
{
|
{
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_apiSettings = apiSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
[ProducesResponseType(typeof(LoginResponseDto), 200)]
|
[ProducesResponseType(typeof(LoginResponseDto), 200)]
|
||||||
|
@ -35,6 +38,7 @@ public class AuthController : ControllerBase
|
||||||
if(result.IsAuthenticated)
|
if(result.IsAuthenticated)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Authentication successful. Returning LoginResponse.");
|
_logger.LogInformation("Authentication successful. Returning LoginResponse.");
|
||||||
|
SetJwtCookie(result.Token);
|
||||||
return Ok(new LoginResponseDto(result.Token, result.RefreshToken));
|
return Ok(new LoginResponseDto(result.Token, result.RefreshToken));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
@ -80,4 +84,16 @@ public class AuthController : ControllerBase
|
||||||
return Problem($"An error occurred on the server: {e.Message}", statusCode: StatusCodes.Status500InternalServerError);
|
return Problem($"An error occurred on the server: {e.Message}", statusCode: StatusCodes.Status500InternalServerError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetJwtCookie(string token)
|
||||||
|
{
|
||||||
|
var cookieOptions = new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = _apiSettings.RunningEnvironment == Enums.RunningEnvironment.Production,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddHours(2)
|
||||||
|
};
|
||||||
|
Response.Cookies.Append("token", token, cookieOptions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Guestbooky.API.Enums;
|
||||||
|
|
||||||
|
public enum RunningEnvironment
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Development,
|
||||||
|
Production
|
||||||
|
}
|
|
@ -1,11 +1,13 @@
|
||||||
using Guestbooky.Application.DependencyInjection;
|
using Guestbooky.Application.DependencyInjection;
|
||||||
using Guestbooky.Infrastructure.DependencyInjection;
|
using Guestbooky.Infrastructure.DependencyInjection;
|
||||||
using Guestbooky.Infrastructure.Environment;
|
using Guestbooky.Infrastructure.Environment;
|
||||||
|
using Guestbooky.API.Configurations;
|
||||||
|
using Guestbooky.API.Enums;
|
||||||
|
using Guestbooky.API.Validations;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Guestbooky.API.Validations;
|
|
||||||
|
|
||||||
namespace Guestbooky.API
|
namespace Guestbooky.API
|
||||||
{
|
{
|
||||||
|
@ -74,9 +76,10 @@ namespace Guestbooky.API
|
||||||
var corsOrigins = builder.Configuration[Constants.CORS_ORIGINS]?.Split(',') ?? Array.Empty<string>();
|
var corsOrigins = builder.Configuration[Constants.CORS_ORIGINS]?.Split(',') ?? Array.Empty<string>();
|
||||||
cfg.AddPolicy(name: "local", policy =>
|
cfg.AddPolicy(name: "local", policy =>
|
||||||
{
|
{
|
||||||
policy.WithExposedHeaders("Content-Range", "Accept-Ranges")
|
policy.WithExposedHeaders("Content-Range", "Accept-Ranges", "Set-Cookie")
|
||||||
.WithOrigins(corsOrigins)
|
.WithOrigins(corsOrigins)
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
|
.AllowCredentials()
|
||||||
.WithMethods("GET", "POST", "DELETE", "OPTIONS");
|
.WithMethods("GET", "POST", "DELETE", "OPTIONS");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -86,7 +89,11 @@ namespace Guestbooky.API
|
||||||
options.InvalidModelStateResponseFactory = InvalidModelStateResponseFactory.DefaultInvalidModelStateResponse;
|
options.InvalidModelStateResponseFactory = InvalidModelStateResponseFactory.DefaultInvalidModelStateResponse;
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(o =>
|
||||||
|
{
|
||||||
|
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
})
|
||||||
.AddJwtBearer(o =>
|
.AddJwtBearer(o =>
|
||||||
{
|
{
|
||||||
o.RequireHttpsMetadata = false;
|
o.RequireHttpsMetadata = false;
|
||||||
|
@ -102,12 +109,22 @@ namespace Guestbooky.API
|
||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.Zero
|
ClockSkew = TimeSpan.Zero
|
||||||
};
|
};
|
||||||
|
o.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
context.Token = context.Request.Cookies["token"];
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(new APISettings(){ RunningEnvironment = GetRunningEnvironment(builder.Configuration["ASPNETCORE_ENVIRONMENT"]!) });
|
||||||
|
|
||||||
|
|
||||||
if (builder.Environment.IsDevelopment())
|
if (builder.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
@ -170,5 +187,12 @@ namespace Guestbooky.API
|
||||||
_ => conf.MinimumLevel.Information()
|
_ => conf.MinimumLevel.Information()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static RunningEnvironment GetRunningEnvironment(string env) => env.ToUpper() switch
|
||||||
|
{
|
||||||
|
"DEVELOPMENT" => RunningEnvironment.Development,
|
||||||
|
"PRODUCTION" => RunningEnvironment.Production,
|
||||||
|
_ => RunningEnvironment.Unknown
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,7 +53,7 @@ namespace Guestbooky.Infrastructure.Persistence.Repositories
|
||||||
var messageDtos = await _messages.Find(_ => true)
|
var messageDtos = await _messages.Find(_ => true)
|
||||||
.SortBy(x => x.Timestamp)
|
.SortBy(x => x.Timestamp)
|
||||||
.Skip((int?)offset)
|
.Skip((int?)offset)
|
||||||
.Limit(50)
|
.Limit(10)
|
||||||
.ToCursorAsync(cancellationToken);
|
.ToCursorAsync(cancellationToken);
|
||||||
return messageDtos.ToEnumerable().Select(MapToDomainModel).ToArray();
|
return messageDtos.ToEnumerable().Select(MapToDomainModel).ToArray();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue