Build base web
This commit is contained in:
0
virtweb_frontend/src/App.css
Normal file
0
virtweb_frontend/src/App.css
Normal file
56
virtweb_frontend/src/App.tsx
Normal file
56
virtweb_frontend/src/App.tsx
Normal 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)!;
|
||||
}
|
82
virtweb_frontend/src/api/ApiClient.ts
Normal file
82
virtweb_frontend/src/api/ApiClient.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
86
virtweb_frontend/src/api/AuthApi.ts
Normal file
86
virtweb_frontend/src/api/AuthApi.ts
Normal 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();
|
||||
}
|
||||
}
|
30
virtweb_frontend/src/api/ServerApi.ts
Normal file
30
virtweb_frontend/src/api/ServerApi.ts
Normal 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;
|
||||
}
|
||||
}
|
9
virtweb_frontend/src/index.css
Normal file
9
virtweb_frontend/src/index.css
Normal file
@ -0,0 +1,9 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
36
virtweb_frontend/src/index.tsx
Normal file
36
virtweb_frontend/src/index.tsx
Normal 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();
|
1
virtweb_frontend/src/react-app-env.d.ts
vendored
Normal file
1
virtweb_frontend/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
15
virtweb_frontend/src/reportWebVitals.ts
Normal file
15
virtweb_frontend/src/reportWebVitals.ts
Normal 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;
|
14
virtweb_frontend/src/routes/NotFound.tsx
Normal file
14
virtweb_frontend/src/routes/NotFound.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
virtweb_frontend/src/routes/auth/LoginRoute.tsx
Normal file
3
virtweb_frontend/src/routes/auth/LoginRoute.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function LoginRoute() {
|
||||
return <></>;
|
||||
}
|
53
virtweb_frontend/src/routes/auth/OIDCCbRoute.tsx
Normal file
53
virtweb_frontend/src/routes/auth/OIDCCbRoute.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
5
virtweb_frontend/src/setupTests.ts
Normal file
5
virtweb_frontend/src/setupTests.ts
Normal 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';
|
92
virtweb_frontend/src/widgets/AsyncWidget.tsx
Normal file
92
virtweb_frontend/src/widgets/AsyncWidget.tsx
Normal 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();
|
||||
}
|
13
virtweb_frontend/src/widgets/AuthSingleMessage.tsx
Normal file
13
virtweb_frontend/src/widgets/AuthSingleMessage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
3
virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx
Normal file
3
virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function BaseAuthenticatedPage(): React.ReactElement {
|
||||
return <>ready with login</>;
|
||||
}
|
90
virtweb_frontend/src/widgets/BaseLoginPage.tsx
Normal file
90
virtweb_frontend/src/widgets/BaseLoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
18
virtweb_frontend/src/widgets/LoadServerConfig.tsx
Normal file
18
virtweb_frontend/src/widgets/LoadServerConfig.tsx
Normal 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}</>}
|
||||
/>
|
||||
);
|
||||
}
|
16
virtweb_frontend/src/widgets/RouterLink.tsx
Normal file
16
virtweb_frontend/src/widgets/RouterLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user