[FEATURE] Add theme support (#1584)
* [FEATURE] Add theme support This change allows users to select a theme for Authelia on start-up. The default will continue to be the existing theme which is known as `light`. Three new options are now also provided: * `dark` * `grey` * `custom` The `custom` theme allows users to specify a primary and secondary hex color code to be utilised to style the portal. Co-authored-by: BankaiNoJutsu <lbegert@gmail.com> * Add themes to integration tests * Remove custom theme * Fix linting issue in access_control_test.go Co-authored-by: BankaiNoJutsu <lbegert@gmail.com>pull/1620/head
parent
b74e65fc48
commit
daa30f3aa3
|
@ -8,6 +8,9 @@ port: 9091
|
|||
# tls_key: /config/ssl/key.pem
|
||||
# tls_cert: /config/ssl/cert.pem
|
||||
|
||||
# The theme to display: light, dark, grey
|
||||
theme: light
|
||||
|
||||
# Configuration options specific to the internal http server
|
||||
server:
|
||||
# Buffers usually should be configured to be the same value.
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
layout: default
|
||||
title: Theme
|
||||
parent: Configuration
|
||||
nav_order: 11
|
||||
---
|
||||
|
||||
# Theme
|
||||
|
||||
The theme section configures the theme and style Authelia uses.
|
||||
|
||||
There are currently 3 available themes for Authelia:
|
||||
* light (default)
|
||||
* dark
|
||||
* grey
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
# The theme to display: light, dark, grey
|
||||
theme: light
|
||||
```
|
|
@ -4,6 +4,7 @@ package schema
|
|||
type Configuration struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Theme string `mapstructure:"theme"`
|
||||
TLSCert string `mapstructure:"tls_cert"`
|
||||
TLSKey string `mapstructure:"tls_key"`
|
||||
CertificatesDirectory string `mapstructure:"certificates_directory"`
|
||||
|
|
|
@ -29,7 +29,7 @@ func (suite *AccessControl) TestShouldValidateCompleteConfiguration() {
|
|||
}
|
||||
|
||||
func (suite *AccessControl) TestShouldRaiseErrorInvalidDefaultPolicy() {
|
||||
suite.configuration.DefaultPolicy = "invalid"
|
||||
suite.configuration.DefaultPolicy = testInvalidPolicy
|
||||
|
||||
ValidateAccessControl(suite.configuration, suite.validator)
|
||||
|
||||
|
@ -71,7 +71,7 @@ func (suite *AccessControl) TestShouldRaiseErrorInvalidPolicy() {
|
|||
suite.configuration.Rules = []schema.ACLRule{
|
||||
{
|
||||
Domains: []string{"public.example.com"},
|
||||
Policy: "invalid",
|
||||
Policy: testInvalidPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -52,6 +52,12 @@ func ValidateConfiguration(configuration *schema.Configuration, validator *schem
|
|||
}
|
||||
}
|
||||
|
||||
if configuration.Theme == "" {
|
||||
configuration.Theme = "light"
|
||||
}
|
||||
|
||||
ValidateTheme(configuration, validator)
|
||||
|
||||
if configuration.TOTP == nil {
|
||||
configuration.TOTP = &schema.DefaultTOTPConfiguration
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ var validKeys = []string{
|
|||
"log_file_path",
|
||||
"default_redirection_url",
|
||||
"jwt_secret",
|
||||
"theme",
|
||||
"tls_key",
|
||||
"tls_cert",
|
||||
"certificates_directory",
|
||||
|
@ -177,6 +178,7 @@ const schemeLDAP = "ldap"
|
|||
const schemeLDAPS = "ldaps"
|
||||
|
||||
const testBadTimer = "-1"
|
||||
const testInvalidPolicy = "invalid"
|
||||
const testJWTSecret = "a_secret"
|
||||
const testLDAPBaseDN = "base_dn"
|
||||
const testLDAPPassword = "password"
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
)
|
||||
|
||||
// ValidateTheme validates and update Theme configuration.
|
||||
func ValidateTheme(configuration *schema.Configuration, validator *schema.StructValidator) {
|
||||
validThemes := regexp.MustCompile("light|dark|grey")
|
||||
if !validThemes.MatchString(configuration.Theme) {
|
||||
validator.Push(fmt.Errorf("Theme: %s is not valid, valid themes are: \"light\", \"dark\" or \"grey\"", configuration.Theme))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
)
|
||||
|
||||
type Theme struct {
|
||||
suite.Suite
|
||||
configuration *schema.Configuration
|
||||
validator *schema.StructValidator
|
||||
}
|
||||
|
||||
func (suite *Theme) SetupTest() {
|
||||
suite.validator = schema.NewStructValidator()
|
||||
suite.configuration = &schema.Configuration{
|
||||
Theme: "light",
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *Theme) TestShouldValidateCompleteConfiguration() {
|
||||
ValidateTheme(suite.configuration, suite.validator)
|
||||
|
||||
suite.Assert().False(suite.validator.HasWarnings())
|
||||
suite.Assert().False(suite.validator.HasErrors())
|
||||
}
|
||||
|
||||
func (suite *Theme) TestShouldRaiseErrorWhenInvalidThemeProvided() {
|
||||
suite.configuration.Theme = "invalid"
|
||||
|
||||
ValidateTheme(suite.configuration, suite.validator)
|
||||
|
||||
suite.Assert().False(suite.validator.HasWarnings())
|
||||
suite.Require().Len(suite.validator.Errors(), 1)
|
||||
|
||||
suite.Assert().EqualError(suite.validator.Errors()[0], "Theme: invalid is not valid, valid themes are: \"light\", \"dark\" or \"grey\"")
|
||||
}
|
||||
|
||||
func TestThemes(t *testing.T) {
|
||||
suite.Run(t, new(Theme))
|
||||
}
|
|
@ -33,9 +33,9 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
|
|||
|
||||
rootFiles := []string{"favicon.ico", "manifest.json", "robots.txt"}
|
||||
|
||||
serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, configuration.Server.Path, configuration.Session.Name, rememberMe, resetPassword)
|
||||
serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, configuration.Server.Path, configuration.Session.Name, rememberMe, resetPassword)
|
||||
serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, configuration.Server.Path, configuration.Session.Name, rememberMe, resetPassword)
|
||||
serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, configuration.Server.Path, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme)
|
||||
serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, configuration.Server.Path, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme)
|
||||
serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, configuration.Server.Path, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme)
|
||||
|
||||
r := router.New()
|
||||
r.GET("/", serveIndexHandler)
|
||||
|
|
|
@ -19,7 +19,7 @@ var alphaNumericRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV
|
|||
// this is utilised to pass information between the backend and frontend
|
||||
// and generate a nonce to support a restrictive CSP while using material-ui.
|
||||
//go:generate broccoli -src ../../public_html -o public_html
|
||||
func ServeTemplatedFile(publicDir, file, base, session, rememberMe, resetPassword string) fasthttp.RequestHandler {
|
||||
func ServeTemplatedFile(publicDir, file, base, rememberMe, resetPassword, session, theme string) fasthttp.RequestHandler {
|
||||
logger := logging.Logger()
|
||||
|
||||
f, err := br.Open(publicDir + file)
|
||||
|
@ -56,7 +56,7 @@ func ServeTemplatedFile(publicDir, file, base, session, rememberMe, resetPasswor
|
|||
ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self' ; object-src 'none'; style-src 'self' 'nonce-%s'", nonce))
|
||||
}
|
||||
|
||||
err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, CSPNonce, Session, RememberMe, ResetPassword string }{Base: base, CSPNonce: nonce, Session: session, RememberMe: rememberMe, ResetPassword: resetPassword})
|
||||
err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, CSPNonce, RememberMe, ResetPassword, Session, Theme string }{Base: base, CSPNonce: nonce, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme})
|
||||
if err != nil {
|
||||
ctx.Error("An error occurred", 503)
|
||||
logger.Errorf("Unable to execute template: %v", err)
|
||||
|
|
|
@ -6,6 +6,8 @@ port: 9091
|
|||
tls_cert: /config/ssl/cert.pem
|
||||
tls_key: /config/ssl/key.pem
|
||||
|
||||
theme: grey
|
||||
|
||||
log_level: debug
|
||||
|
||||
default_redirection_url: https://home.example.com:8080/
|
||||
|
|
|
@ -6,6 +6,8 @@ port: 9091
|
|||
tls_cert: /config/ssl/cert.pem
|
||||
tls_key: /config/ssl/key.pem
|
||||
|
||||
theme: dark
|
||||
|
||||
log_level: debug
|
||||
|
||||
default_redirection_url: https://home.example.com:8080/
|
||||
|
|
|
@ -2,3 +2,4 @@ HOST=authelia-frontend
|
|||
PUBLIC_URL=""
|
||||
REACT_APP_REMEMBER_ME=true
|
||||
REACT_APP_RESET_PASSWORD=true
|
||||
REACT_APP_THEME=light
|
|
@ -1,3 +1,4 @@
|
|||
PUBLIC_URL={{.Base}}
|
||||
REACT_APP_REMEMBER_ME={{.RememberMe}}
|
||||
REACT_APP_RESET_PASSWORD={{.ResetPassword}}
|
||||
REACT_APP_THEME={{.Theme}}
|
|
@ -25,7 +25,7 @@
|
|||
<title>Login - Authelia</title>
|
||||
</head>
|
||||
|
||||
<body data-basepath="%PUBLIC_URL%" data-rememberme="%REACT_APP_REMEMBER_ME%" data-resetpassword="%REACT_APP_RESET_PASSWORD%">
|
||||
<body data-basepath="%PUBLIC_URL%" data-rememberme="%REACT_APP_REMEMBER_ME%" data-resetpassword="%REACT_APP_RESET_PASSWORD%" data-theme="%REACT_APP_THEME%">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { config as faConfig } from "@fortawesome/fontawesome-svg-core";
|
||||
import { CssBaseline, ThemeProvider } from "@material-ui/core";
|
||||
import { BrowserRouter as Router, Route, Switch, Redirect } from "react-router-dom";
|
||||
|
||||
import NotificationBar from "./components/NotificationBar";
|
||||
|
@ -14,8 +15,9 @@ import {
|
|||
RegisterOneTimePasswordRoute,
|
||||
LogoutRoute,
|
||||
} from "./Routes";
|
||||
import * as themes from "./themes";
|
||||
import { getBasePath } from "./utils/BasePath";
|
||||
import { getRememberMe, getResetPassword } from "./utils/Configuration";
|
||||
import { getRememberMe, getResetPassword, getTheme } from "./utils/Configuration";
|
||||
import RegisterOneTimePassword from "./views/DeviceRegistration/RegisterOneTimePassword";
|
||||
import RegisterSecurityKey from "./views/DeviceRegistration/RegisterSecurityKey";
|
||||
import LoginPortal from "./views/LoginPortal/LoginPortal";
|
||||
|
@ -27,38 +29,52 @@ import "@fortawesome/fontawesome-svg-core/styles.css";
|
|||
|
||||
faConfig.autoAddCss = false;
|
||||
|
||||
function Theme() {
|
||||
switch (getTheme()) {
|
||||
case "dark":
|
||||
return themes.Dark;
|
||||
case "grey":
|
||||
return themes.Grey;
|
||||
default:
|
||||
return themes.Light;
|
||||
}
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [notification, setNotification] = useState(null as Notification | null);
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={{ notification, setNotification }}>
|
||||
<Router basename={getBasePath()}>
|
||||
<NotificationBar onClose={() => setNotification(null)} />
|
||||
<Switch>
|
||||
<Route path={ResetPasswordStep1Route} exact>
|
||||
<ResetPasswordStep1 />
|
||||
</Route>
|
||||
<Route path={ResetPasswordStep2Route} exact>
|
||||
<ResetPasswordStep2 />
|
||||
</Route>
|
||||
<Route path={RegisterSecurityKeyRoute} exact>
|
||||
<RegisterSecurityKey />
|
||||
</Route>
|
||||
<Route path={RegisterOneTimePasswordRoute} exact>
|
||||
<RegisterOneTimePassword />
|
||||
</Route>
|
||||
<Route path={LogoutRoute} exact>
|
||||
<SignOut />
|
||||
</Route>
|
||||
<Route path={FirstFactorRoute}>
|
||||
<LoginPortal rememberMe={getRememberMe()} resetPassword={getResetPassword()} />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<Redirect to={FirstFactorRoute} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</NotificationsContext.Provider>
|
||||
<ThemeProvider theme={Theme()}>
|
||||
<CssBaseline />
|
||||
<NotificationsContext.Provider value={{ notification, setNotification }}>
|
||||
<Router basename={getBasePath()}>
|
||||
<NotificationBar onClose={() => setNotification(null)} />
|
||||
<Switch>
|
||||
<Route path={ResetPasswordStep1Route} exact>
|
||||
<ResetPasswordStep1 />
|
||||
</Route>
|
||||
<Route path={ResetPasswordStep2Route} exact>
|
||||
<ResetPasswordStep2 />
|
||||
</Route>
|
||||
<Route path={RegisterSecurityKeyRoute} exact>
|
||||
<RegisterSecurityKey />
|
||||
</Route>
|
||||
<Route path={RegisterOneTimePasswordRoute} exact>
|
||||
<RegisterOneTimePassword />
|
||||
</Route>
|
||||
<Route path={LogoutRoute} exact>
|
||||
<SignOut />
|
||||
</Route>
|
||||
<Route path={FirstFactorRoute}>
|
||||
<LoginPortal rememberMe={getRememberMe()} resetPassword={getResetPassword()} />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<Redirect to={FirstFactorRoute} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</NotificationsContext.Provider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
<svg version="1.1" id="UserSvg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 55 55" style="enable-background:new 0 0 55 55;" xml:space="preserve">
|
||||
<path d="M55,27.5C55,12.337,42.663,0,27.5,0S0,12.337,0,27.5c0,8.009,3.444,15.228,8.926,20.258l-0.026,0.023l0.892,0.752
|
||||
c0.058,0.049,0.121,0.089,0.179,0.137c0.474,0.393,0.965,0.766,1.465,1.127c0.162,0.117,0.324,0.234,0.489,0.348
|
||||
|
@ -18,34 +18,4 @@
|
|||
s-7.024,1.065-8.867,3.168c-2.119,2.416-1.935,5.346-1.883,5.864v4.667c-0.568,0.661-0.887,1.502-0.887,2.369v3.545
|
||||
c0,1.101,0.494,2.128,1.34,2.821c0.81,3.173,2.477,5.575,3.093,6.389v2.894c0,0.816-0.445,1.566-1.162,1.958l-7.907,4.313
|
||||
c-0.252,0.137-0.502,0.297-0.752,0.476C5.276,41.792,2,35.022,2,27.5z"/>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
@ -1,4 +1,5 @@
|
|||
import "./utils/AssetPath";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import ReactDOM from "react-dom";
|
||||
|
|
|
@ -52,7 +52,6 @@ const useStyles = makeStyles((theme) => ({
|
|||
root: {
|
||||
minHeight: "90vh",
|
||||
textAlign: "center",
|
||||
// marginTop: theme.spacing(10),
|
||||
},
|
||||
rootContainer: {
|
||||
paddingLeft: 32,
|
||||
|
@ -62,6 +61,7 @@ const useStyles = makeStyles((theme) => ({
|
|||
icon: {
|
||||
margin: theme.spacing(),
|
||||
width: "64px",
|
||||
fill: theme.custom.icon,
|
||||
},
|
||||
body: {},
|
||||
poweredBy: {
|
||||
|
|
|
@ -3,4 +3,5 @@ import Adapter from "enzyme-adapter-react-16";
|
|||
document.body.setAttribute("data-basepath", "");
|
||||
document.body.setAttribute("data-rememberme", "true");
|
||||
document.body.setAttribute("data-resetpassword", "true");
|
||||
document.body.setAttribute("data-theme", "light");
|
||||
configure({ adapter: new Adapter() });
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { createMuiTheme } from "@material-ui/core/styles";
|
||||
|
||||
const Dark = createMuiTheme({
|
||||
custom: {
|
||||
icon: "#fff",
|
||||
loadingBar: "#fff",
|
||||
},
|
||||
palette: {
|
||||
type: "dark",
|
||||
primary: {
|
||||
main: "#1976d2",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default Dark;
|
|
@ -0,0 +1,59 @@
|
|||
import { createMuiTheme } from "@material-ui/core/styles";
|
||||
|
||||
const Grey = createMuiTheme({
|
||||
custom: {
|
||||
icon: "#929aa5",
|
||||
loadingBar: "#929aa5",
|
||||
},
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#929aa5",
|
||||
},
|
||||
background: {
|
||||
default: "#2f343e",
|
||||
paper: "#2f343e",
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiCssBaseline: {
|
||||
"@global": {
|
||||
body: {
|
||||
backgroundColor: "#2f343e",
|
||||
color: "#929aa5",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
root: {
|
||||
"& $notchedOutline": {
|
||||
borderColor: "#929aa5",
|
||||
},
|
||||
"&:hover:not($disabled):not($focused):not($error) $notchedOutline": {
|
||||
borderColor: "#929aa5",
|
||||
borderWidth: 2,
|
||||
},
|
||||
"&$focused $notchedOutline": {
|
||||
borderColor: "#929aa5",
|
||||
},
|
||||
},
|
||||
notchedOutline: {},
|
||||
},
|
||||
MuiCheckbox: {
|
||||
root: {
|
||||
color: "#929aa5",
|
||||
},
|
||||
},
|
||||
MuiInputBase: {
|
||||
input: {
|
||||
color: "#929aa5",
|
||||
},
|
||||
},
|
||||
MuiInputLabel: {
|
||||
root: {
|
||||
color: "#929aa5",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default Grey;
|
|
@ -0,0 +1,19 @@
|
|||
import { createMuiTheme } from "@material-ui/core/styles";
|
||||
|
||||
const Light = createMuiTheme({
|
||||
custom: {
|
||||
icon: "#000",
|
||||
loadingBar: "#000",
|
||||
},
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#1976d2",
|
||||
},
|
||||
background: {
|
||||
default: "#fff",
|
||||
paper: "#fff",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default Light;
|
|
@ -0,0 +1,18 @@
|
|||
declare module "@material-ui/core/styles/createMuiTheme" {
|
||||
interface Theme {
|
||||
custom: {
|
||||
icon: React.CSSProperties["color"];
|
||||
loadingBar: React.CSSProperties["color"];
|
||||
};
|
||||
}
|
||||
interface ThemeOptions {
|
||||
custom: {
|
||||
icon: React.CSSProperties["color"];
|
||||
loadingBar: React.CSSProperties["color"];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { default as Light } from "./Light";
|
||||
export { default as Dark } from "./Dark";
|
||||
export { default as Grey } from "./Grey";
|
|
@ -14,3 +14,7 @@ export function getRememberMe() {
|
|||
export function getResetPassword() {
|
||||
return getEmbeddedVariable("resetpassword") === "true";
|
||||
}
|
||||
|
||||
export function getTheme() {
|
||||
return getEmbeddedVariable("theme");
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import React from "react";
|
||||
|
||||
import { Typography, Grid } from "@material-ui/core";
|
||||
import { useTheme, Typography, Grid } from "@material-ui/core";
|
||||
import ReactLoading from "react-loading";
|
||||
|
||||
const LoadingPage = function () {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Grid container alignItems="center" justify="center" style={{ minHeight: "100vh" }}>
|
||||
<Grid item style={{ textAlign: "center", display: "inline-block" }}>
|
||||
<ReactLoading width={64} height={64} color="black" type="bars" />
|
||||
<ReactLoading width={64} height={64} color={theme.custom.loadingBar} type="bars" />
|
||||
<Typography>Loading...</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
Loading…
Reference in New Issue