Add authentication layer
This commit is contained in:
		@@ -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