Add authentication layer
This commit is contained in:
1
central_frontend/.env
Normal file
1
central_frontend/.env
Normal file
@ -0,0 +1 @@
|
||||
VITE_APP_BACKEND=https://localhost:8443/web_api
|
1
central_frontend/.env.production
Normal file
1
central_frontend/.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_APP_BACKEND=/web_api
|
@ -1,5 +1,10 @@
|
||||
import { AuthApi } from "./api/AuthApi";
|
||||
import { ServerApi } from "./api/ServerApi";
|
||||
import { LoginRoute } from "./routes/LoginRoute";
|
||||
|
||||
export function App() {
|
||||
return <LoginRoute />;
|
||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||
return <LoginRoute />;
|
||||
|
||||
return <>logged in todo</>;
|
||||
}
|
||||
|
177
central_frontend/src/api/ApiClient.ts
Normal file
177
central_frontend/src/api/ApiClient.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { AuthApi } from "./AuthApi";
|
||||
|
||||
interface RequestParams {
|
||||
uri: string;
|
||||
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||
allowFail?: boolean;
|
||||
jsonData?: any;
|
||||
formData?: FormData;
|
||||
upProgress?: (progress: number) => void;
|
||||
downProgress?: (e: { progress: number; total: number }) => void;
|
||||
}
|
||||
|
||||
interface APIResponse {
|
||||
data: any;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public code: number, public data: any) {
|
||||
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class APIClient {
|
||||
/**
|
||||
* Get backend URL
|
||||
*/
|
||||
static backendURL(): string {
|
||||
const URL = import.meta.env.VITE_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: RequestParams): Promise<APIResponse> {
|
||||
let body: string | undefined | FormData = 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 url = this.backendURL() + args.uri;
|
||||
|
||||
let data;
|
||||
let status: number;
|
||||
|
||||
// Make the request with XMLHttpRequest
|
||||
if (args.upProgress) {
|
||||
const res: XMLHttpRequest = await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (e) =>
|
||||
args.upProgress!(e.loaded / e.total)
|
||||
);
|
||||
xhr.addEventListener("load", () => resolve(xhr));
|
||||
xhr.addEventListener("error", () =>
|
||||
reject(new Error("File upload failed"))
|
||||
);
|
||||
xhr.addEventListener("abort", () =>
|
||||
reject(new Error("File upload aborted"))
|
||||
);
|
||||
xhr.addEventListener("timeout", () =>
|
||||
reject(new Error("File upload timeout"))
|
||||
);
|
||||
xhr.open(args.method, url, true);
|
||||
xhr.withCredentials = true;
|
||||
for (const key in headers) {
|
||||
if (headers.hasOwnProperty(key))
|
||||
xhr.setRequestHeader(key, headers[key]);
|
||||
}
|
||||
xhr.send(body);
|
||||
});
|
||||
|
||||
status = res.status;
|
||||
if (res.responseType === "json") data = JSON.parse(res.responseText);
|
||||
else data = res.response;
|
||||
}
|
||||
|
||||
// Make the request with fetch
|
||||
else {
|
||||
const res = await fetch(url, {
|
||||
method: args.method,
|
||||
body: body,
|
||||
headers: headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
// Process response
|
||||
// JSON response
|
||||
if (res.headers.get("content-type") === "application/json")
|
||||
data = await res.json();
|
||||
// Text / XML response
|
||||
else if (
|
||||
["application/xml", "text/plain"].includes(
|
||||
res.headers.get("content-type") ?? ""
|
||||
)
|
||||
)
|
||||
data = await res.text();
|
||||
// Binary file, tracking download progress
|
||||
else if (res.body !== null && args.downProgress) {
|
||||
// Track download progress
|
||||
const contentEncoding = res.headers.get("content-encoding");
|
||||
const contentLength = contentEncoding
|
||||
? null
|
||||
: res.headers.get("content-length");
|
||||
|
||||
const total = parseInt(contentLength ?? "0", 10);
|
||||
let loaded = 0;
|
||||
|
||||
const resInt = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const reader = res.body!.getReader();
|
||||
|
||||
const read = async () => {
|
||||
try {
|
||||
const ret = await reader.read();
|
||||
if (ret.done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
loaded += ret.value.byteLength;
|
||||
args.downProgress!({ progress: loaded, total });
|
||||
controller.enqueue(ret.value);
|
||||
read();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
controller.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
read();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
data = await resInt.blob();
|
||||
}
|
||||
|
||||
// Do not track progress (binary file)
|
||||
else data = await res.blob();
|
||||
|
||||
status = res.status;
|
||||
}
|
||||
|
||||
// Handle expired tokens
|
||||
if (status === 412) {
|
||||
AuthApi.UnsetAuthenticated();
|
||||
window.location.href = import.meta.env.VITE_APP_BASENAME;
|
||||
}
|
||||
|
||||
if (!args.allowFail && (status < 200 || status > 299))
|
||||
throw new ApiError("Request failed!", status, data);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
status: status,
|
||||
};
|
||||
}
|
||||
}
|
70
central_frontend/src/api/AuthApi.ts
Normal file
70
central_frontend/src/api/AuthApi.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface AuthInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
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 user and password
|
||||
*/
|
||||
static async AuthWithPassword(user: string, password: string): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: "/auth/password_auth",
|
||||
method: "POST",
|
||||
jsonData: {
|
||||
user,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
this.SetAuthenticated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth information
|
||||
*/
|
||||
static async GetAuthInfo(): Promise<AuthInfo> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
uri: "/auth/info",
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
static async SignOut(): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: "/auth/sign_out",
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
this.UnsetAuthenticated();
|
||||
}
|
||||
}
|
29
central_frontend/src/api/ServerApi.ts
Normal file
29
central_frontend/src/api/ServerApi.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface ServerConfig {
|
||||
auth_disabled: boolean;
|
||||
}
|
||||
|
||||
let config: ServerConfig | null = null;
|
||||
|
||||
export class ServerApi {
|
||||
/**
|
||||
* Get server configuration
|
||||
*/
|
||||
static async LoadConfig(): Promise<void> {
|
||||
config = (
|
||||
await APIClient.exec({
|
||||
uri: "/server/config",
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached configuration
|
||||
*/
|
||||
static get Config(): ServerConfig {
|
||||
if (config === null) throw new Error("Missing configuration!");
|
||||
return config;
|
||||
}
|
||||
}
|
@ -72,10 +72,10 @@ export function ConfirmDialogProvider(
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => handleClose(false)} autoFocus>
|
||||
Annuler
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => handleClose(true)} color="error">
|
||||
{confirmButton ?? "Confirmer"}
|
||||
{confirmButton ?? "Confirm"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
@ -11,6 +11,8 @@ import { DarkThemeProvider } from "./hooks/context_providers/DarkThemeProvider";
|
||||
import { LoadingMessageProvider } from "./hooks/context_providers/LoadingMessageProvider";
|
||||
import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider";
|
||||
import "./index.css";
|
||||
import { ServerApi } from "./api/ServerApi";
|
||||
import { AsyncWidget } from "./widgets/AsyncWidget";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
@ -19,7 +21,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<ConfirmDialogProvider>
|
||||
<SnackbarProvider>
|
||||
<LoadingMessageProvider>
|
||||
<App />
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
load={async () => await ServerApi.LoadConfig()}
|
||||
errMsg="Failed to connect to backend to retrieve static config!"
|
||||
build={() => <App />}
|
||||
/>
|
||||
</LoadingMessageProvider>
|
||||
</SnackbarProvider>
|
||||
</ConfirmDialogProvider>
|
||||
|
@ -1,16 +1,18 @@
|
||||
import * as React from "react";
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Alert } from "@mui/material";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Link from "@mui/material/Link";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import * as React from "react";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { AuthApi } from "../api/AuthApi";
|
||||
|
||||
function Copyright(props: any) {
|
||||
return (
|
||||
@ -31,12 +33,28 @@ function Copyright(props: any) {
|
||||
}
|
||||
|
||||
export function LoginRoute() {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const [user, setUser] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
// TODO
|
||||
try {
|
||||
loadingMessage.show("Signing in...");
|
||||
setError(undefined);
|
||||
|
||||
await AuthApi.AuthWithPassword(user, password);
|
||||
|
||||
location.href = "/";
|
||||
} catch (e) {
|
||||
console.error("Failed to perform login!", e);
|
||||
setError(`Failed to authenticate! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -73,6 +91,9 @@ export function LoginRoute() {
|
||||
<Typography component="h1" variant="h5">
|
||||
SolarEnergy
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
<Box
|
||||
component="form"
|
||||
noValidate
|
||||
|
92
central_frontend/src/widgets/AsyncWidget.tsx
Normal file
92
central_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();
|
||||
}
|
Reference in New Issue
Block a user