Merge pull request 'Front-end panel' (#1) from admin into main
Some checks are pending
CI/CD / build-and-test (push) Waiting to run

Reviewed-on: #1
This commit is contained in:
Felipe Cotti 2024-11-18 04:39:54 +01:00
commit 93be26fac8
26 changed files with 5047 additions and 7 deletions

View file

@ -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>
@ -29,6 +29,8 @@
- [📑 Documentation ](#-documentation-)
- [🏁 Getting Started ](#-getting-started-)
- [🕸️ Prerequisites](#-prerequisites)
- [Backend:](#backend)
- [Admin panel:](#admin-panel)
- [🚀 Deployment ](#-deployment-)
- [⛏️ Built Using ](#-built-using-)
- [✍️ Authors ](#-authors-)
@ -49,6 +51,8 @@ These instructions will get you a copy of the project up and running on your loc
## 🕸️ Prerequisites
### Backend:
For running it locally:
- .NET 8.0
- 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]
> 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>
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
- [.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
## ✍️ Authors <a name = "authors"></a>

View file

@ -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.
- 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
View 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?

View 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 },
],
},
},
]

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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;
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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;
}

View 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;

View 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;
}

View 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;

View file

@ -0,0 +1 @@
export const API_URL = 'http://localhost:8080';

View 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;

View 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;

View 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%);
}

View 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 />
)

View 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 });
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View file

@ -0,0 +1,8 @@
using Guestbooky.API.Enums;
namespace Guestbooky.API.Configurations;
public class APISettings
{
public required RunningEnvironment RunningEnvironment { get; set; }
}

View file

@ -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.RefreshToken;
using MediatR;
@ -12,11 +13,13 @@ public class AuthController : ControllerBase
{
private readonly IMediator _mediator;
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;
_logger = logger;
_apiSettings = apiSettings;
}
[ProducesResponseType(typeof(LoginResponseDto), 200)]
@ -35,6 +38,7 @@ public class AuthController : ControllerBase
if(result.IsAuthenticated)
{
_logger.LogInformation("Authentication successful. Returning LoginResponse.");
SetJwtCookie(result.Token);
return Ok(new LoginResponseDto(result.Token, result.RefreshToken));
}
else
@ -80,4 +84,16 @@ public class AuthController : ControllerBase
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);
}
}

View file

@ -0,0 +1,8 @@
namespace Guestbooky.API.Enums;
public enum RunningEnvironment
{
Unknown = 0,
Development,
Production
}

View file

@ -1,11 +1,13 @@
using Guestbooky.Application.DependencyInjection;
using Guestbooky.Infrastructure.DependencyInjection;
using Guestbooky.Infrastructure.Environment;
using Guestbooky.API.Configurations;
using Guestbooky.API.Enums;
using Guestbooky.API.Validations;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using Serilog;
using Guestbooky.API.Validations;
namespace Guestbooky.API
{
@ -74,9 +76,10 @@ namespace Guestbooky.API
var corsOrigins = builder.Configuration[Constants.CORS_ORIGINS]?.Split(',') ?? Array.Empty<string>();
cfg.AddPolicy(name: "local", policy =>
{
policy.WithExposedHeaders("Content-Range", "Accept-Ranges")
policy.WithExposedHeaders("Content-Range", "Accept-Ranges", "Set-Cookie")
.WithOrigins(corsOrigins)
.AllowAnyHeader()
.AllowCredentials()
.WithMethods("GET", "POST", "DELETE", "OPTIONS");
});
});
@ -86,7 +89,11 @@ namespace Guestbooky.API
options.InvalidModelStateResponseFactory = InvalidModelStateResponseFactory.DefaultInvalidModelStateResponse;
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
builder.Services.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o =>
{
o.RequireHttpsMetadata = false;
@ -102,12 +109,22 @@ namespace Guestbooky.API
ValidateLifetime = true,
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.AddApplication();
builder.Services.AddSingleton(new APISettings(){ RunningEnvironment = GetRunningEnvironment(builder.Configuration["ASPNETCORE_ENVIRONMENT"]!) });
if (builder.Environment.IsDevelopment())
{
@ -170,5 +187,12 @@ namespace Guestbooky.API
_ => conf.MinimumLevel.Information()
};
public static RunningEnvironment GetRunningEnvironment(string env) => env.ToUpper() switch
{
"DEVELOPMENT" => RunningEnvironment.Development,
"PRODUCTION" => RunningEnvironment.Production,
_ => RunningEnvironment.Unknown
};
}
}

View file

@ -53,7 +53,7 @@ namespace Guestbooky.Infrastructure.Persistence.Repositories
var messageDtos = await _messages.Find(_ => true)
.SortBy(x => x.Timestamp)
.Skip((int?)offset)
.Limit(50)
.Limit(10)
.ToCursorAsync(cancellationToken);
return messageDtos.ToEnumerable().Select(MapToDomainModel).ToArray();
}