[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>
This commit is contained in:
Amir Zarrinkafsh 2021-01-20 23:07:40 +11:00 committed by GitHub
parent b74e65fc48
commit daa30f3aa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 279 additions and 74 deletions

View File

@ -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.

View File

@ -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
```

View File

@ -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"`

View File

@ -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,
},
}

View File

@ -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
}

View File

@ -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"

View File

@ -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))
}
}

View File

@ -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))
}

View File

@ -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)

View File

@ -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)

View File

@ -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/

View File

@ -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/

View File

@ -1,4 +1,5 @@
HOST=authelia-frontend
PUBLIC_URL=""
REACT_APP_REMEMBER_ME=true
REACT_APP_RESET_PASSWORD=true
REACT_APP_RESET_PASSWORD=true
REACT_APP_THEME=light

View File

@ -1,3 +1,4 @@
PUBLIC_URL={{.Base}}
REACT_APP_REMEMBER_ME={{.RememberMe}}
REACT_APP_RESET_PASSWORD={{.ResetPassword}}
REACT_APP_RESET_PASSWORD={{.ResetPassword}}
REACT_APP_THEME={{.Theme}}

View File

@ -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>
<!--

View File

@ -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>
);
};

View File

@ -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>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,4 +1,5 @@
import "./utils/AssetPath";
import React from "react";
import ReactDOM from "react-dom";

View File

@ -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: {

View File

@ -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() });

16
web/src/themes/Dark.ts Normal file
View File

@ -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;

59
web/src/themes/Grey.ts Normal file
View File

@ -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;

19
web/src/themes/Light.ts Normal file
View File

@ -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;

18
web/src/themes/index.ts Normal file
View File

@ -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";

View File

@ -14,3 +14,7 @@ export function getRememberMe() {
export function getResetPassword() {
return getEmbeddedVariable("resetpassword") === "true";
}
export function getTheme() {
return getEmbeddedVariable("theme");
}

View File

@ -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>