Build base web

This commit is contained in:
2023-09-04 14:11:56 +02:00
parent 83bd87c6f8
commit 8defc104c6
35 changed files with 31630 additions and 0 deletions

View File

View File

@ -0,0 +1,56 @@
import React from "react";
import "./App.css";
import {
Route,
RouterProvider,
createBrowserRouter,
createRoutesFromElements,
} from "react-router-dom";
import { NotFoundRoute } from "./routes/NotFound";
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
import { BaseLoginPage } from "./widgets/BaseLoginPage";
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
import { LoginRoute } from "./routes/auth/LoginRoute";
import { AuthApi } from "./api/AuthApi";
interface AuthContext {
signedIn: boolean;
setSignedIn: (signedIn: boolean) => void;
}
const AuthContextK = React.createContext<AuthContext | null>(null);
export function App() {
const [signedIn, setSignedIn] = React.useState(AuthApi.SignedIn);
const context: AuthContext = {
signedIn: signedIn,
setSignedIn: (s) => setSignedIn(s),
};
const router = createBrowserRouter(
createRoutesFromElements(
signedIn ? (
<Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="*" element={<NotFoundRoute />} />
</Route>
) : (
<Route path="*" element={<BaseLoginPage />}>
<Route path="" element={<LoginRoute />} />
<Route path="oidc_cb" element={<OIDCCbRoute />} />
<Route path="*" element={<NotFoundRoute />} />
</Route>
)
)
);
return (
<AuthContextK.Provider value={context}>
<RouterProvider router={router} />
</AuthContextK.Provider>
);
}
export function useAuth(): AuthContext {
return React.useContext(AuthContextK)!;
}

View File

@ -0,0 +1,82 @@
import { AuthApi } from "./AuthApi";
interface APIResponse {
data: any;
status: number;
}
export class ApiError extends Error {
constructor(message: string, public code: number, public data: any) {
super(message);
}
}
export class APIClient {
/**
* Get backend URL
*/
static backendURL(): string {
const URL = process.env.REACT_APP_BACKEND ?? "";
if (URL.length === 0) throw new Error("Backend URL undefined!");
return URL;
}
/**
* Check out whether the backend is accessed through
* HTTPS or not
*/
static IsBackendSecure(): boolean {
return this.backendURL().startsWith("https");
}
/**
* Perform a request on the backend
*/
static async exec(args: {
uri: string;
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
allowFail?: boolean;
jsonData?: any;
formData?: FormData;
}): Promise<APIResponse> {
let body = undefined;
let headers: any = {};
// JSON request
if (args.jsonData) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(args.jsonData);
}
// Form data request
else if (args.formData) {
body = args.formData;
}
const res = await fetch(this.backendURL() + args.uri, {
method: args.method,
body: body,
headers: headers,
});
// Process response
let data;
if (res.headers.get("content-type") === "application/json")
data = await res.json();
else data = await res.blob();
// Handle expired tokens
if (res.status === 412) {
AuthApi.UnsetAuthenticated();
window.location.href = "/";
}
if (!args.allowFail && !res.ok)
throw new ApiError("Request failed!", res.status, data);
return {
data: data,
status: res.status,
};
}
}

View File

@ -0,0 +1,86 @@
import { APIClient } from "./ApiClient";
const TokenStateKey = "auth-state";
export class AuthApi {
/**
* Check out whether user is signed in or not
*/
static get SignedIn(): boolean {
return localStorage.getItem(TokenStateKey) !== null;
}
/**
* Mark user as authenticated
*/
static SetAuthenticated() {
localStorage.setItem(TokenStateKey, "");
}
/**
* Un-mark user as authenticated
*/
static UnsetAuthenticated() {
localStorage.removeItem(TokenStateKey);
}
/**
* Authenticate using an username and a password
*
* @param username The username to use
* @param password The password to use
*/
static async LoginWithPassword(
username: string,
password: string
): Promise<void> {
await APIClient.exec({
uri: "/auth/local",
method: "POST",
allowFail: true,
jsonData: {
username: username,
password: password,
},
});
this.SetAuthenticated();
}
/**
* Start OpenID login
*/
static async StartOpenIDLogin(): Promise<{ url: string }> {
return (
await APIClient.exec({
uri: "/auth/start_oidc",
method: "GET",
})
).data;
}
/**
* Finish OpenID login
*/
static async FinishOpenIDLogin(code: string, state: string): Promise<void> {
await APIClient.exec({
uri: "/auth/finish_oidc",
method: "POST",
jsonData: { code: code, state: state },
});
this.SetAuthenticated();
}
/**
* Sign out
*/
static async SignOut(): Promise<void> {
await APIClient.exec({
uri: "/auth/sign_out",
method: "GET",
});
this.UnsetAuthenticated();
}
}

View File

@ -0,0 +1,30 @@
import { APIClient } from "./ApiClient";
export interface ServerConfig {
local_auth_enabled: boolean;
oidc_auth_enabled: boolean;
}
let config: ServerConfig | null = null;
export class ServerApi {
/**
* Get server configuration
*/
static async LoadConfig(): Promise<void> {
config = (
await APIClient.exec({
uri: "/server/static_config",
method: "GET",
})
).data;
}
/**
* Get cached configuration
*/
static get Config(): ServerConfig {
if (config === null) throw new Error("Missing configuration!");
return config;
}
}

View File

@ -0,0 +1,9 @@
body {
margin: 0;
}
html,
body,
#root {
height: 100%;
}

View File

@ -0,0 +1,36 @@
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./index.css";
import reportWebVitals from "./reportWebVitals";
import { LoadServerConfig } from "./widgets/LoadServerConfig";
import { ThemeProvider, createTheme } from "@mui/material";
const darkTheme = createTheme({
palette: {
mode: "dark",
},
});
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<ThemeProvider theme={darkTheme}>
<LoadServerConfig>
<App />
</LoadServerConfig>
</ThemeProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -0,0 +1,14 @@
import { Button } from "@mui/material";
import { RouterLink } from "../widgets/RouterLink";
export function NotFoundRoute(): React.ReactElement {
return (
<div style={{ textAlign: "center" }}>
<h1>Page non trouvée !</h1>
<p>La page que vous demandez n'a pas été trouvée !</p>
<RouterLink to="/">
<Button>Retour à l'accueil</Button>
</RouterLink>
</div>
);
}

View File

@ -0,0 +1,3 @@
export function LoginRoute() {
return <></>;
}

View File

@ -0,0 +1,53 @@
import { CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { AuthApi } from "../../api/AuthApi";
import { useAuth } from "../../App";
import { AuthSingleMessage } from "../../widgets/AuthSingleMessage";
/**
* OpenID login callback route
*/
export function OIDCCbRoute(): React.ReactElement {
const auth = useAuth();
const navigate = useNavigate();
const [error, setError] = useState(false);
const [searchParams] = useSearchParams();
const code = searchParams.get("code");
const state = searchParams.get("state");
const count = useRef("");
useEffect(() => {
const load = async () => {
try {
if (count.current === code) {
return;
}
count.current = code!;
await AuthApi.FinishOpenIDLogin(code!, state!);
navigate("/");
auth.setSignedIn(true);
} catch (e) {
console.error(e);
setError(true);
}
};
load();
});
if (error)
return (
<AuthSingleMessage message="Echec de la finalisation de l'authentification !" />
);
return (
<>
<CircularProgress />
</>
);
}

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -0,0 +1,92 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
enum State {
Loading,
Ready,
Error,
}
export function AsyncWidget(p: {
loadKey: any;
load: () => Promise<void>;
errMsg: string;
build: () => React.ReactElement;
ready?: boolean;
errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement {
const [state, setState] = useState(State.Loading);
const counter = useRef<any | null>(null);
const load = async () => {
try {
setState(State.Loading);
await p.load();
setState(State.Ready);
} catch (e) {
console.error(e);
setState(State.Error);
}
};
useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
load();
});
if (state === State.Error)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
flexDirection: "column",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
<Alert
variant="outlined"
severity="error"
style={{ margin: "0px 15px 15px 15px" }}
>
{p.errMsg}
</Alert>
<Button onClick={load}>Try again</Button>
{p.errAdditionalElement && p.errAdditionalElement()}
</Box>
);
if (state === State.Loading || p.ready === false)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
<CircularProgress />
</Box>
);
return p.build();
}

View File

@ -0,0 +1,13 @@
import { Button } from "@mui/material";
import { Link } from "react-router-dom";
export function AuthSingleMessage(p: { message: string }): React.ReactElement {
return (
<>
<p style={{ textAlign: "center" }}>{p.message}</p>
<Link to={"/"}>
<Button>Retour à l'accueil</Button>
</Link>
</>
);
}

View File

@ -0,0 +1,3 @@
export function BaseAuthenticatedPage(): React.ReactElement {
return <>ready with login</>;
}

View File

@ -0,0 +1,90 @@
import { mdiServer } from "@mdi/js";
import Icon from "@mdi/react";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline";
import Grid from "@mui/material/Grid";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import { Link, Outlet } from "react-router-dom";
function Copyright(props: any) {
return (
<Typography
variant="body2"
color="text.secondary"
align="center"
style={{ marginTop: "20px" }}
{...props}
>
{"Copyright © "}
<a
color="inherit"
href="https://0ph.fr/"
target="_blank"
rel="noreferrer"
style={{ color: "inherit" }}
>
Pierre HUBERT
</a>{" "}
{new Date().getFullYear()}
{"."}
</Typography>
);
}
export function BaseLoginPage() {
return (
<Grid container component="main" sx={{ height: "100vh" }}>
<CssBaseline />
<Grid
item
xs={false}
sm={4}
md={7}
sx={{
backgroundImage: "url(/login_splash.jpg)",
backgroundRepeat: "no-repeat",
backgroundColor: (t) =>
t.palette.mode === "light"
? t.palette.grey[50]
: t.palette.grey[900],
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<Grid item xs={12} sm={8} md={5} component={Paper} elevation={6} square>
<Box
sx={{
my: 8,
mx: 4,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<Icon path={mdiServer} size={1} />
</Avatar>
<Link to="/" style={{ color: "inherit", textDecoration: "none" }}>
<Typography component="h1" variant="h5">
VirtWeb
</Typography>
</Link>
<Typography
component="h1"
variant="h6"
style={{ margin: "10px 0px 30px 0px" }}
>
Virtual Machines Management
</Typography>
{/* inner page */}
<Outlet />
<Copyright sx={{ mt: 5 }} />
</Box>
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,18 @@
import { PropsWithChildren } from "react";
import { AsyncWidget } from "./AsyncWidget";
import { ServerApi } from "../api/ServerApi";
export function LoadServerConfig(p: PropsWithChildren): React.ReactElement {
const load = async () => {
await ServerApi.LoadConfig();
};
return (
<AsyncWidget
loadKey={0}
errMsg="Failed to load server config!"
load={load}
build={() => <>{p.children}</>}
/>
);
}

View File

@ -0,0 +1,16 @@
import { PropsWithChildren } from "react";
import { Link } from "react-router-dom";
export function RouterLink(
p: PropsWithChildren<{ to: string; target?: React.HTMLAttributeAnchorTarget }>
): React.ReactElement {
return (
<Link
to={p.to}
target={p.target}
style={{ color: "inherit", textDecoration: "inherit" }}
>
{p.children}
</Link>
);
}