diff --git a/config.template.yml b/config.template.yml index 125653bd6..e953ad7fe 100644 --- a/config.template.yml +++ b/config.template.yml @@ -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. diff --git a/docs/configuration/theme.md b/docs/configuration/theme.md new file mode 100644 index 000000000..3d1e88fc5 --- /dev/null +++ b/docs/configuration/theme.md @@ -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 +``` \ No newline at end of file diff --git a/internal/configuration/schema/configuration.go b/internal/configuration/schema/configuration.go index 92601a656..040b64a79 100644 --- a/internal/configuration/schema/configuration.go +++ b/internal/configuration/schema/configuration.go @@ -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"` diff --git a/internal/configuration/validator/access_control_test.go b/internal/configuration/validator/access_control_test.go index 34bf057ac..c57e46a19 100644 --- a/internal/configuration/validator/access_control_test.go +++ b/internal/configuration/validator/access_control_test.go @@ -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, }, } diff --git a/internal/configuration/validator/configuration.go b/internal/configuration/validator/configuration.go index 90145d515..ea60e42df 100644 --- a/internal/configuration/validator/configuration.go +++ b/internal/configuration/validator/configuration.go @@ -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 } diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 88a8c44b0..87242ab95 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -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" diff --git a/internal/configuration/validator/theme.go b/internal/configuration/validator/theme.go new file mode 100644 index 000000000..a3f12e014 --- /dev/null +++ b/internal/configuration/validator/theme.go @@ -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)) + } +} diff --git a/internal/configuration/validator/theme_test.go b/internal/configuration/validator/theme_test.go new file mode 100644 index 000000000..054f61247 --- /dev/null +++ b/internal/configuration/validator/theme_test.go @@ -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)) +} diff --git a/internal/server/server.go b/internal/server/server.go index 05b0af3a4..660071a41 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/template.go b/internal/server/template.go index 6717ebcc2..7153cd8c1 100644 --- a/internal/server/template.go +++ b/internal/server/template.go @@ -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) diff --git a/internal/suites/ActiveDirectory/configuration.yml b/internal/suites/ActiveDirectory/configuration.yml index cfe729102..24fddbdba 100644 --- a/internal/suites/ActiveDirectory/configuration.yml +++ b/internal/suites/ActiveDirectory/configuration.yml @@ -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/ diff --git a/internal/suites/LDAP/configuration.yml b/internal/suites/LDAP/configuration.yml index c9a9f728c..bda70ba86 100644 --- a/internal/suites/LDAP/configuration.yml +++ b/internal/suites/LDAP/configuration.yml @@ -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/ diff --git a/web/.env.development b/web/.env.development index dc50b8c79..9dcec55d1 100644 --- a/web/.env.development +++ b/web/.env.development @@ -1,4 +1,5 @@ HOST=authelia-frontend PUBLIC_URL="" REACT_APP_REMEMBER_ME=true -REACT_APP_RESET_PASSWORD=true \ No newline at end of file +REACT_APP_RESET_PASSWORD=true +REACT_APP_THEME=light \ No newline at end of file diff --git a/web/.env.production b/web/.env.production index 1f6321076..2c5e2cd67 100644 --- a/web/.env.production +++ b/web/.env.production @@ -1,3 +1,4 @@ PUBLIC_URL={{.Base}} REACT_APP_REMEMBER_ME={{.RememberMe}} -REACT_APP_RESET_PASSWORD={{.ResetPassword}} \ No newline at end of file +REACT_APP_RESET_PASSWORD={{.ResetPassword}} +REACT_APP_THEME={{.Theme}} \ No newline at end of file diff --git a/web/public/index.html b/web/public/index.html index 16a5d184e..2d1407d6f 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -25,7 +25,7 @@ Login - Authelia - +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/web/src/index.tsx b/web/src/index.tsx index 1fb7ad7b9..c77f5cf35 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,4 +1,5 @@ import "./utils/AssetPath"; + import React from "react"; import ReactDOM from "react-dom"; diff --git a/web/src/layouts/LoginLayout.tsx b/web/src/layouts/LoginLayout.tsx index 20017c388..858abc8da 100644 --- a/web/src/layouts/LoginLayout.tsx +++ b/web/src/layouts/LoginLayout.tsx @@ -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: { diff --git a/web/src/setupTests.js b/web/src/setupTests.js index 81b86e1d0..77c90bb59 100644 --- a/web/src/setupTests.js +++ b/web/src/setupTests.js @@ -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() }); diff --git a/web/src/themes/Dark.ts b/web/src/themes/Dark.ts new file mode 100644 index 000000000..faa19c0fb --- /dev/null +++ b/web/src/themes/Dark.ts @@ -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; diff --git a/web/src/themes/Grey.ts b/web/src/themes/Grey.ts new file mode 100644 index 000000000..045ddc5ea --- /dev/null +++ b/web/src/themes/Grey.ts @@ -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; diff --git a/web/src/themes/Light.ts b/web/src/themes/Light.ts new file mode 100644 index 000000000..c0d493d63 --- /dev/null +++ b/web/src/themes/Light.ts @@ -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; diff --git a/web/src/themes/index.ts b/web/src/themes/index.ts new file mode 100644 index 000000000..0e2da1e69 --- /dev/null +++ b/web/src/themes/index.ts @@ -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"; diff --git a/web/src/utils/Configuration.ts b/web/src/utils/Configuration.ts index 81e279974..a84dc308a 100644 --- a/web/src/utils/Configuration.ts +++ b/web/src/utils/Configuration.ts @@ -14,3 +14,7 @@ export function getRememberMe() { export function getResetPassword() { return getEmbeddedVariable("resetpassword") === "true"; } + +export function getTheme() { + return getEmbeddedVariable("theme"); +} diff --git a/web/src/views/LoadingPage/LoadingPage.tsx b/web/src/views/LoadingPage/LoadingPage.tsx index 6ae4ac779..f1860346f 100644 --- a/web/src/views/LoadingPage/LoadingPage.tsx +++ b/web/src/views/LoadingPage/LoadingPage.tsx @@ -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 ( - + Loading...