Implement Login page using react

This commit is contained in:
Aurora Lahtela 2022-08-18 18:27:11 +03:00
parent 767eb844c9
commit b5005afbc5
13 changed files with 366 additions and 22 deletions

View File

@ -222,6 +222,11 @@ public class PageFactory {
}
public Page loginPage() throws IOException {
if (config.get().isTrue(PluginSettings.FRONTEND_BETA)) {
String reactHtml = getResource("index.html");
return () -> reactHtml;
}
return new LoginPage(getResource("login.html"), serverInfo.get(), locale.get(), theme.get(), versionChecker.get());
}

View File

@ -35,6 +35,7 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.util.Optional;
@ -42,12 +43,19 @@ import java.util.Optional;
@Path("/v1/metadata")
public class MetadataJSONResolver implements NoAuthResolver {
private final String mainCommand;
private final PlanConfig config;
private final Theme theme;
private final ServerInfo serverInfo;
@Inject
public MetadataJSONResolver(PlanConfig config, Theme theme, ServerInfo serverInfo) {
public MetadataJSONResolver(
@Named("mainCommandName") String mainCommand,
PlanConfig config,
Theme theme,
ServerInfo serverInfo
) {
this.mainCommand = mainCommand;
this.config = config;
// Dagger inject constructor
this.theme = theme;
@ -74,6 +82,7 @@ public class MetadataJSONResolver implements NoAuthResolver {
.put("isProxy", serverInfo.getServer().isProxy())
.put("serverName", serverInfo.getServer().getIdentifiableName())
.put("networkName", serverInfo.getServer().isProxy() ? config.get(ProxySettings.NETWORK_NAME) : null)
.put("mainCommand", mainCommand)
.build())
.build();
}

View File

@ -46,6 +46,7 @@ function drawSine(canvasId) {
function draw() {
const canvas = document.getElementById(canvasId);
if (canvas == null) return;
const context = canvas.getContext("2d");
context.clearRect(0, 0, 1000, 150);
@ -60,6 +61,7 @@ function drawSine(canvasId) {
function fix_dpi() {
const canvas = document.getElementById(canvasId);
if (canvas == null) return;
let dpi = window.devicePixelRatio;
canvas.getContext('2d');
const style_width = getComputedStyle(canvas).getPropertyValue("width").slice(0, -2);

View File

@ -28,6 +28,7 @@ import ServerPlayers from "./views/server/ServerPlayers";
import PlayersPage from "./views/layout/PlayersPage";
import AllPlayers from "./views/players/AllPlayers";
import ServerGeolocations from "./views/server/ServerGeolocations";
import LoginPage from "./views/layout/LoginPage";
const SwaggerView = React.lazy(() => import("./views/SwaggerView"));
@ -57,6 +58,8 @@ function App() {
<BrowserRouter>
<Routes>
<Route path="" element={<MainPageRedirect/>}/>
<Route path="/" element={<MainPageRedirect/>}/>
<Route path="/login" element={<LoginPage/>}/>
<Route path="/player/:identifier" element={<PlayerPage/>}>
<Route path="" element={<OverviewRedirect/>}/>
<Route path="overview" element={<PlayerOverview/>}/>
@ -86,8 +89,13 @@ function App() {
<Route path="geolocations" element={<ServerGeolocations/>}/>
<Route path="performance" element={<></>}/>
<Route path="plugins-overview" element={<></>}/>
<Route path="*" element={<ErrorView error={{
message: 'Unknown tab address, please correct the address',
title: 'No such tab',
icon: faMapSigns
}}/>}/>
</Route>
<Route path="docs" element={<React.Suspense fallback={<></>}>
<Route path="/docs" element={<React.Suspense fallback={<></>}>
<SwaggerView/>
</React.Suspense>}/>
</Routes>

View File

@ -24,7 +24,7 @@ const ColorSelectorModal = () => {
aria-labelledby="colorChooserModalLabel"
show={theme.colorChooserOpen}
onHide={theme.toggleColorChooser}>
<Modal.Header>
<Modal.Header className="bg-white">
<Modal.Title id="colorChooserModalLabel">
<Fa icon={faPalette}/> {t('html.label.themeSelect')}
</Modal.Title>

View File

@ -0,0 +1,37 @@
import React from 'react';
import {Modal} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faHandPointRight} from "@fortawesome/free-regular-svg-icons";
import {useTranslation} from "react-i18next";
import {useMetadata} from "../../hooks/metadataHook";
import {Link} from "react-router-dom";
const ForgotPasswordModal = ({show, toggle}) => {
const {t} = useTranslation();
const {mainCommand} = useMetadata();
return (
<Modal id="forgotPasswordModal"
aria-labelledby="forgotModalLabel"
show={show}
onHide={toggle}
>
<Modal.Header className="bg-white">
<Modal.Title id="forgotModalLabel">
<Fa icon={faHandPointRight}/> {t('html.login.forgotPassword1')}
</Modal.Title>
<button aria-label="Close" className="btn-close" onClick={toggle}/>
</Modal.Header>
<Modal.Body className="bg-white">
<p>{t('html.login.forgotPassword2')}</p>
<p><code>/{mainCommand || 'plan'} unregister</code></p>
<p>{t('html.login.forgotPassword3')}</p>
<p><code>/{mainCommand || 'plan'} unregister [username]</code></p>
<p>{t('html.login.forgotPassword4')} <Link to="/register"
className="col-plan">{t('html.login.register')}</Link></p>
</Modal.Body>
</Modal>
)
};
export default ForgotPasswordModal

View File

@ -49,19 +49,18 @@ const MainPageRedirect = () => {
const {authLoaded, authRequired, loggedIn, user} = useAuth();
const {isProxy, serverName} = useMetadata();
console.log(authLoaded, authRequired, loggedIn, user)
if (!authLoaded || !serverName) {
return <RedirectPlaceholder/>
}
if (authRequired && !loggedIn) {
return (<Navigate to={"login"} replace={true}/>)
return (<Navigate to="login" replace={true}/>)
} else if (authRequired && loggedIn) {
if (isProxy && user.permissions.includes('page.network')) {
return (<Navigate to={"network/overview"} replace={true}/>)
} else if (user.permissions.includes('page.server')) {
return (<Navigate to={"server/overview"} replace={true}/>)
return (<Navigate to={"server/" + encodeURIComponent(serverName) + "/overview"} replace={true}/>)
} else if (user.permissions.includes('page.player.other')) {
return (<Navigate to={"players"} replace={true}/>)
} else if (user.permissions.includes('page.player.self')) {

View File

@ -23,15 +23,6 @@ export const AuthenticationContextProvider = ({children}) => {
}
}, [])
const login = useCallback(async (username, password) => {
// TODO implement later when login page is done with React
await updateLoginDetails();
}, [updateLoginDetails]);
const logout = useCallback(() => {
// TODO implement later when login page is done with React
}, []);
const hasPermission = useCallback(permission => {
return !authRequired || (loggedIn && user && user.permissions.filter(perm => perm === permission).length);
}, [authRequired, loggedIn, user]);
@ -49,11 +40,10 @@ export const AuthenticationContextProvider = ({children}) => {
authRequired,
loggedIn,
user,
login,
logout,
loginError,
hasPermission,
hasPermissionOtherThan
hasPermissionOtherThan,
updateLoginDetails
}
return (<AuthenticationContext.Provider value={sharedState}>
{children}

View File

@ -1,6 +1,11 @@
import {doGetRequest} from "./backendConfiguration";
import {doGetRequest, doSomePostRequest, standard200option} from "./backendConfiguration";
export const fetchWhoAmI = async () => {
const url = '/v1/whoami';
return doGetRequest(url);
}
export const fetchLogin = async (username, password) => {
const url = '/auth/login';
return doSomePostRequest(url, [standard200option], `user=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`);
}

View File

@ -11,9 +11,17 @@ const isCurrentAddress = (address) => {
export const baseAddress = "PLAN_BASE_ADDRESS" === toBeReplaced || !isCurrentAddress(toBeReplaced) ? "" : toBeReplaced;
export const doSomeGetRequest = async (url, statusOptions) => {
return doSomeRequest(url, statusOptions, async () => axios.get(url));
}
export const doSomePostRequest = async (url, statusOptions, body) => {
return doSomeRequest(url, statusOptions, async () => axios.post(url, body));
}
export const doSomeRequest = async (url, statusOptions, axiosFunction) => {
let response = undefined;
try {
response = await axios.get(baseAddress + url);
response = await axiosFunction.call();
for (const statusOption of statusOptions) {
if (response.status === statusOption.status) {

View File

@ -0,0 +1,75 @@
// https://gist.github.com/gkhays/e264009c0832c73d5345847e673a64ab
export default function drawSine(canvasId) {
let step;
function drawPoint(ctx, x, y) {
const radius = 2;
ctx.beginPath();
// Hold x constant at 4 so the point only moves up and down.
ctx.arc(x - 5, y, radius, 0, 2 * Math.PI, false);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.lineWidth = 1;
ctx.stroke();
}
function plotSine(ctx, xOffset) {
const width = ctx.canvas.width;
const height = ctx.canvas.height;
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = "#fff";
// Drawing point
let x = -2;
let y = 0;
const amplitude = 50;
const frequency = 50;
ctx.moveTo(x, 50);
while (x <= width) {
y = height / 2 + amplitude * Math.sin((x + xOffset) / frequency) * Math.cos((x + xOffset) / (frequency * 0.54515978463));
ctx.lineTo(x, y);
x += 5;
}
ctx.stroke();
ctx.save();
drawPoint(ctx, x, y);
ctx.stroke();
ctx.restore();
}
function draw() {
const canvas = document.getElementById(canvasId);
if (canvas == null) return;
const context = canvas.getContext("2d");
context.clearRect(0, 0, 1000, 150);
context.save();
plotSine(context, step);
context.restore();
step += 0.5;
window.requestAnimationFrame(draw);
}
function fix_dpi() {
const canvas = document.getElementById(canvasId);
if (canvas == null) return;
let dpi = window.devicePixelRatio;
canvas.getContext('2d');
const style_width = getComputedStyle(canvas).getPropertyValue("width").slice(0, -2);
// Scale the canvas
canvas.setAttribute('width', `${style_width * dpi}`);
}
fix_dpi();
step = -1;
window.requestAnimationFrame(draw);
}

View File

@ -0,0 +1,208 @@
import React, {useCallback, useEffect, useState} from 'react';
import logo from '../../Flaticon_circle.png'
import {Alert, Card, Col, Row} from "react-bootstrap-v5";
import {Link, useNavigate} from "react-router-dom";
import {useTranslation} from "react-i18next";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faPalette} from "@fortawesome/free-solid-svg-icons";
import {useTheme} from "../../hooks/themeHook";
import ColorSelectorModal from "../../components/modal/ColorSelectorModal";
import drawSine from "../../util/loginSineRenderer";
import {fetchLogin} from "../../service/authenticationService";
import ForgotPasswordModal from "../../components/modal/ForgotPasswordModal";
import {useAuth} from "../../hooks/authenticationHook";
const Logo = () => {
return (
<Col md={12} className='mt-5 text-center'>
<img alt="logo" className="w-15" src={logo}/>
</Col>
)
};
const LoginCard = ({children}) => {
return (
<Row className="justify-content-center container-fluid">
<Col xl={6} lg={7} md={9}>
<Card className='o-hidden border-0 shadow-lg my-5'>
<Card.Body className='p-0'>
<Row>
<Col lg={12}>
<div className='p-5'>
{children}
</div>
</Col>
</Row>
</Card.Body>
</Card>
</Col>
</Row>
)
}
const LoginForm = ({login}) => {
const {t} = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const onLogin = async event => {
event.preventDefault();
await login(username, password);
setPassword('');
}
return (
<form className="user">
<div className="mb-3">
<input autoComplete="username" className="form-control form-control-user"
id="inputUser"
placeholder={t('html.login.username')} type="text"
value={username} onChange={event => setUsername(event.target.value)}/>
</div>
<div className="mb-3">
<input autoComplete="current-password" className="form-control form-control-user"
id="inputPassword" placeholder={t('html.login.password')} type="password"
value={password} onChange={event => setPassword(event.target.value)}/>
</div>
<button className="btn bg-plan btn-user w-100" id="login-button" onClick={onLogin}>
Login
</button>
</form>
);
}
const ColorChooserButton = () => {
const {t} = useTranslation();
const {toggleColorChooser} = useTheme();
return (
<div className='text-center'>
<button className="btn col-plan" onClick={toggleColorChooser}
title={t('html.label.themeSelect')}>
<Fa icon={faPalette}/>
</button>
</div>
)
}
const ForgotPasswordButton = ({onClick}) => {
const {t} = useTranslation();
return (
<div className='text-center'>
<button className='col-plan small' onClick={onClick}>{t('html.login.forgotPassword')}</button>
</div>
)
}
const CreateAccountLink = () => {
const {t} = useTranslation();
return (
<div className='text-center'>
<Link to='/register' className='col-plan small'>{t('html.login.register')}</Link>
</div>
)
}
const Decoration = () => {
useEffect(() => {
drawSine('decoration');
})
return (
<Row className='justify-content-center'>
<canvas className="col-xl-3 col-lg-3 col-md-5" id="decoration" style={{height: "100px"}}/>
</Row>
);
}
const LoginPage = () => {
const {t} = useTranslation();
const navigate = useNavigate();
const {authLoaded, authRequired, loggedIn, updateLoginDetails} = useAuth();
const [forgotPasswordModalOpen, setForgotPasswordModalOpen] = useState(false);
const [failMessage, setFailMessage] = useState('');
const [redirectTo, setRedirectTo] = useState(undefined);
const togglePasswordModal = useCallback(() => setForgotPasswordModalOpen(!forgotPasswordModalOpen),
[setForgotPasswordModalOpen, forgotPasswordModalOpen])
useEffect(() => {
document.body.classList.add("bg-plan", "plan-bg-gradient");
const urlParams = new URLSearchParams(window.location.search);
const cameFrom = urlParams.get('from');
if (cameFrom) setRedirectTo(cameFrom);
return () => {
document.body.classList.remove("bg-plan", "plan-bg-gradient");
}
}, [setRedirectTo])
const login = async (username, password) => {
if (!username || username.length < 1) {
return setFailMessage(t('html.register.error.noUsername'));
}
if (username.length > 50) {
return setFailMessage(t('html.register.error.usernameLength') + username.length);
}
if (!password || password.length < 1) {
return setFailMessage(t('html.register.error.noPassword'));
}
const {data, error} = await fetchLogin(username, password);
if (error) {
if (error.message === 'Request failed with status code 403') {
// Too many logins, reload browser to show forbidden page
window.location.reload();
} else {
setFailMessage(t('html.login.failed') + (error.data && error.data.error ? error.data.error : error.message));
}
} else if (data && data.success) {
if (redirectTo && !redirectTo.startsWith('http') && !redirectTo.startsWith('file') && !redirectTo.startsWith('javascript')) {
navigate(redirectTo.substring(redirectTo.indexOf('/')))
} else {
await updateLoginDetails();
navigate('../');
}
} else {
setFailMessage(t('html.login.failed') + data ? data.error : t('generic.noData'));
}
}
if (!authLoaded) {
return <></>
}
if (!authRequired || loggedIn) {
navigate('../');
}
return (
<>
<main className="container">
<Logo/>
<LoginCard>
{failMessage && <Alert className='alert-danger'>{failMessage}</Alert>}
<LoginForm login={login}/>
<hr className="bg-secondary"/>
<ForgotPasswordButton onClick={togglePasswordModal}/>
<CreateAccountLink/>
<ColorChooserButton/>
</LoginCard>
<Decoration/>
</main>
<aside>
<ColorSelectorModal/>
<ForgotPasswordModal show={forgotPasswordModalOpen} toggle={togglePasswordModal}/>
</aside>
</>
)
};
export default LoginPage

View File

@ -14,8 +14,6 @@ const ServerPvpPve = () => {
const {data, loadingError} = useDataRequest(fetchPvpPve, [identifier]);
const {data: killsData, loadingError: killsLoadingError} = useDataRequest(fetchKills, [identifier]);
console.log(killsData)
if (!data || !killsData) return <></>;
if (loadingError) return <ErrorView error={loadingError}/>
if (killsLoadingError) return <ErrorView error={killsLoadingError}/>