From 6cd1241afa7f43277a1830d8ca46601bae7fe687 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Wed, 17 Jan 2018 09:37:08 +0100 Subject: [PATCH 1/2] Refactor to early returns as much as possible in the do_GET logic --- libcodechecker/server/server.py | 212 ++++++++++++++++---------------- 1 file changed, 107 insertions(+), 105 deletions(-) diff --git a/libcodechecker/server/server.py b/libcodechecker/server/server.py index ff84da7837..99262e9fa3 100644 --- a/libcodechecker/server/server.py +++ b/libcodechecker/server/server.py @@ -157,118 +157,120 @@ def do_GET(self): auth_session.user if auth_session else 'Anonymous', self.path)) - if self.server.manager.is_enabled and not auth_session: - realm = self.server.manager.get_realm()['realm'] - error_body = self.server.manager.get_realm()['error'] - - self.send_response(401) # 401 Unauthorised - self.send_header('WWW-Authenticate', - 'Basic realm="{0}"'.format(realm)) - self.send_header('Content-type', 'text/plain') - self.send_header('Content-length', str(len(error_body))) - self.send_header('Connection', 'close') + if not self.path.startswith('/login.html') and \ + self.server.manager.is_enabled and not auth_session: + returnto = '?returnto=' + self.path.ltrim('/') \ + if self.path != '/' else '' + + self.send_response(307) # 307 Temporary Redirect + self.send_header("Location", '/login.html' + returnto) self.end_headers() - self.wfile.write(error_body) return - else: - if auth_session is not None: - self.auth_token = auth_session.token - product_endpoint, path = \ - routing.split_client_GET_request(self.path) - - if product_endpoint is not None and product_endpoint != '': - product = self.server.get_product(product_endpoint) - - if not product: - LOG.info("Product endpoint '{0}' does not exist." - .format(product_endpoint)) - self.send_error( - 404, - "The product {0} does not exist." - .format(product_endpoint)) - return - - if product: - # Try to reconnect in these cases. - # Do not try to reconnect if there is a schema mismatch. - reconnect_cases = [DBStatus.FAILED_TO_CONNECT, - DBStatus.MISSING, - DBStatus.SCHEMA_INIT_ERROR] - # If the product is not connected, try reconnecting... - if product.db_status in reconnect_cases: - LOG.error("Request's product '{0}' is not connected! " - "Attempting reconnect..." - .format(product_endpoint)) - product.connect() - if product.db_status != DBStatus.OK: - # If the reconnection fails, - # redirect user to the products page. - self.send_response(307) # 307 Temporary Redirect - self.send_header("Location", '/products.html') - self.end_headers() - return - - if path == '' and not self.path.endswith('/'): - # /prod must be routed to /prod/index.html first, so later - # queries for web resources are '/prod/style...' as - # opposed to '/style...', which would result in "style" - # being considered product name. - LOG.debug("Redirecting user from /{0} to /{0}/index.html" + if auth_session is not None: + self.auth_token = auth_session.token + + product_endpoint, path = routing.split_client_GET_request(self.path) + + if product_endpoint is not None and product_endpoint != '': + # Route the user if there is a product endpoint in the request. + + product = self.server.get_product(product_endpoint) + if not product: + # Give an error if the user tries to access an invalid product. + LOG.info("Product endpoint '{0}' does not exist." + .format(product_endpoint)) + self.send_error( + 404, + "The product {0} does not exist." + .format(product_endpoint)) + return + + if product: + # Try to reconnect in these cases. + # Do not try to reconnect if there is a schema mismatch. + reconnect_cases = [DBStatus.FAILED_TO_CONNECT, + DBStatus.MISSING, + DBStatus.SCHEMA_INIT_ERROR] + # If the product is not connected, try reconnecting... + if product.db_status in reconnect_cases: + LOG.error("Request's product '{0}' is not connected! " + "Attempting reconnect..." .format(product_endpoint)) - - # WARN: Browsers cache '308 Permanent Redirect' responses, - # in the event of debugging this, use Private Browsing! - self.send_response(308) - self.send_header("Location", - self.path.replace(product_endpoint, - product_endpoint + '/', - 1)) - self.end_headers() - return - else: - # Serves the main page and the resources: - # /prod/(index.html) -> /(index.html) - # /prod/styles/(...) -> /styles/(...) - LOG.debug("Product routing before " + self.path) - self.path = self.path.replace( - "{0}/".format(product_endpoint), "", 1) - LOG.debug("Product routing after: " + self.path) + product.connect() + if product.db_status != DBStatus.OK: + # If the reconnection fails, + # redirect user to the products page. + self.send_response(307) # 307 Temporary Redirect + self.send_header("Location", '/products.html') + self.end_headers() + return + + if path == '' and not self.path.endswith('/'): + # /prod must be routed to /prod/index.html first, so later + # queries for web resources are '/prod/style...' as + # opposed to '/style...', which would result in 'style' + # being considered product name. + LOG.debug("Redirecting user from /{0} to /{0}/index.html" + .format(product_endpoint)) + + # WARN: Browsers cache '308 Permanent Redirect' responses, + # in the event of debugging this, use Private Browsing! + self.send_response(308) + self.send_header("Location", + self.path.replace(product_endpoint, + product_endpoint + '/', + 1)) + self.end_headers() + return + + # In other cases when '/prod/' is already in the request, + # serve the main page and the resources, for example: + # /prod/(index.html) -> /(index.html) + # /prod/styles/(...) -> /styles/(...) + LOG.debug("Product routing before " + self.path) + self.path = self.path.replace( + "{0}/".format(product_endpoint), "", 1) + LOG.debug("Product routing after: " + self.path) + else: + # No product endpoint in the request. + + if self.path in ['/', '/index.html']: + # In case the homepage is requested and only one product + # exists, try to skip the product list and redirect the user + # to the runs immediately. + only_product = self.server.get_only_product() + if only_product: + if only_product.db_status == DBStatus.OK: + LOG.debug("Redirecting '/' to ONLY product '/{0}'" + .format(only_product.endpoint)) + + self.send_response(307) # 307 Temporary Redirect + self.send_header("Location", + '/{0}'.format(only_product.endpoint)) + self.end_headers() + return + else: + LOG.debug("ONLY product '/{0}' has database issues..." + .format(only_product.endpoint)) + + self.send_response(307) # 307 Temporary Redirect + self.send_header("Location", '/products.html') + self.end_headers() + return + + # If multiple products exist, route homepage queries to + # serve the product list. + LOG.debug("Serving product list as homepage.") + self.path = '/products.html' else: + # The path requested does not specify a product: it is most + # likely a resource file. + LOG.debug("Serving resource '{0}'".format(self.path)) - if self.path in ['/', '/index.html']: - only_product = self.server.get_only_product() - if only_product: - prod_db_status = only_product.db_status - if prod_db_status == DBStatus.OK: - LOG.debug("Redirecting '/' to ONLY product '/{0}'" - .format(only_product.endpoint)) - - self.send_response(307) # 307 Temporary Redirect - msg = '/{0}'.format(only_product.endpoint) - self.send_header("Location", msg) - self.end_headers() - return - else: - LOG.debug("Redirecting '/' to ONLY product '/{0}'" - .format(only_product.endpoint)) - - self.send_response(307) # 307 Temporary Redirect - self.send_header("Location", '/products.html') - self.end_headers() - return - - # Route homepage queries to serving the product list. - LOG.debug("Serving product list as homepage.") - self.path = '/products.html' - else: - # The path requested does not specify a product: it is most - # likely a resource file. - LOG.debug("Serving resource '{0}'".format(self.path)) - - self.send_response(200) # 200 OK + self.send_response(200) # 200 OK - SimpleHTTPRequestHandler.do_GET(self) + SimpleHTTPRequestHandler.do_GET(self) # Actual serving of file. @staticmethod def __check_prod_db(product): From ae3405de5d668bca6d2b4b4b41d3992dae6c6a7b Mon Sep 17 00:00:00 2001 From: Whisperity Date: Wed, 17 Jan 2018 10:51:00 +0100 Subject: [PATCH 2/2] Add a login form and a logout button to the WebGUI --- api/v6/authentication.thrift | 17 +- libcodechecker/libclient/client.py | 2 +- libcodechecker/server/routing.py | 20 +- libcodechecker/server/server.py | 38 ++-- libcodechecker/version.py | 2 +- www/login.html | 63 ++++++ www/scripts/codecheckerviewer/HeaderPane.js | 33 ++- www/scripts/login/login.js | 239 ++++++++++++++++++++ www/scripts/version.js | 1 + www/style/codecheckerviewer.css | 11 +- www/style/login.css | 26 +++ 11 files changed, 410 insertions(+), 42 deletions(-) create mode 100644 www/login.html create mode 100644 www/scripts/login/login.js create mode 100644 www/style/login.css diff --git a/api/v6/authentication.thrift b/api/v6/authentication.thrift index 17e8b48d37..87f08de866 100644 --- a/api/v6/authentication.thrift +++ b/api/v6/authentication.thrift @@ -10,8 +10,8 @@ namespace py Authentication_v6 namespace js codeCheckerAuthentication_v6 struct HandshakeInformation { - 1: bool requiresAuthentication, // true if the server has a privileged zone --- the state of having a valid access is not considered here - 2: bool sessionStillActive // whether the session in which the HandshakeInformation is returned is a valid one + 1: bool requiresAuthentication, // True if the server has a privileged zone. + 2: bool sessionStillActive // Whether the session in which the HandshakeInformation is returned is a live one } struct AuthorisationList { @@ -36,24 +36,25 @@ service codeCheckerAuthentication { throws (1: shared.RequestFailed requestError), // ============= Authentication and session handling ============= - // get basic authentication information from the server + // Get basic authentication information from the server. HandshakeInformation getAuthParameters(), - // retrieves a list of accepted authentication methods from the server + // Retrieves a list of accepted authentication methods from the server. list getAcceptedAuthMethods(), - // handles creating a session token for the user + // Handles creating a session token for the user. string performLogin(1: string authMethod, 2: string authString) throws (1: shared.RequestFailed requestError), - // performs logout action for the user (must be called from the corresponding valid session) + // Performs logout action for the user. Must be called from the + // corresponding valid session which is to be destroyed. bool destroySession() throws (1: shared.RequestFailed requestError), - // returns currently logged in user within the active session - // returns empty string if the session is not active + // Returns the currently logged in user within the active session, or empty + // string if no authenticated session is active. string getLoggedInUser() throws (1: shared.RequestFailed requestError), diff --git a/libcodechecker/libclient/client.py b/libcodechecker/libclient/client.py index 1e559ee8e4..52209e05b1 100644 --- a/libcodechecker/libclient/client.py +++ b/libcodechecker/libclient/client.py @@ -124,7 +124,7 @@ def handle_auth(protocol, host, port, username, login=False): pwd = saved_auth.split(":")[1] else: LOG.info("Logging in using credentials from command line...") - pwd = getpass.getpass("Please provide password for user '{0}'" + pwd = getpass.getpass("Please provide password for user '{0}': " .format(username)) LOG.debug("Trying to login as {0} to {1}:{2}" diff --git a/libcodechecker/server/routing.py b/libcodechecker/server/routing.py index 35dd146b22..9f0df04db7 100644 --- a/libcodechecker/server/routing.py +++ b/libcodechecker/server/routing.py @@ -14,8 +14,9 @@ # A list of top-level path elements under the webserver root # which should not be considered as a product route. -NON_PRODUCT_ENDPOINTS = ['products.html', - 'index.html', +NON_PRODUCT_ENDPOINTS = ['index.html', + 'login.html', + 'products.html', 'fonts', 'images', 'scripts', @@ -31,6 +32,13 @@ ] +# A list of top-level path elements under the webserver root which should +# be protected by authentication requirement when accessing the server. +PROTECTED_ENTRY_POINTS = ['', # Empty string in a request is 'index.html'. + 'index.html', + 'products.html'] + + def is_valid_product_endpoint(uripart): """ Returns whether or not the given URI part is to be considered a valid @@ -122,3 +130,11 @@ def split_client_POST_request(path): remainder = split_path[2] return None, version_tag, remainder + + +def is_protected_GET_entrypoint(path): + """ + Returns if the given GET request's PATH enters the server through an + entry point which is considered protected by authentication requirements. + """ + return path in PROTECTED_ENTRY_POINTS diff --git a/libcodechecker/server/server.py b/libcodechecker/server/server.py index 99262e9fa3..cacab80d64 100644 --- a/libcodechecker/server/server.py +++ b/libcodechecker/server/server.py @@ -108,21 +108,6 @@ def __check_auth_in_request(self): success = self.server.manager.get_session(values[1], True) - if success is None: - # Session cookie was invalid (or not found...) - # Attempt to see if the browser has sent us - # an authentication request. - authHeader = self.headers.getheader("Authorization") - if authHeader is not None and authHeader.startswith("Basic "): - authString = base64.decodestring( - self.headers.getheader("Authorization"). - replace("Basic ", "")) - - session = self.server.manager.create_or_get_session( - authString) - if session: - return session - # Else, access is still not granted. if success is None: LOG.debug(client_host + ":" + str(client_port) + @@ -147,7 +132,7 @@ def end_headers(self): def do_GET(self): """ - Handles the webbrowser access (GET requests). + Handles the browser access (GET requests). """ auth_session = self.__check_auth_in_request() @@ -157,21 +142,24 @@ def do_GET(self): auth_session.user if auth_session else 'Anonymous', self.path)) - if not self.path.startswith('/login.html') and \ - self.server.manager.is_enabled and not auth_session: - returnto = '?returnto=' + self.path.ltrim('/') \ + if auth_session is not None: + self.auth_token = auth_session.token + + product_endpoint, path = routing.split_client_GET_request(self.path) + + if self.server.manager.is_enabled and not auth_session \ + and routing.is_protected_GET_entrypoint(path): + # If necessary, prompt the user for authentication. + returnto = '#returnto=' + urllib.quote_plus(self.path.lstrip('/'))\ if self.path != '/' else '' self.send_response(307) # 307 Temporary Redirect - self.send_header("Location", '/login.html' + returnto) + self.send_header('Location', '/login.html' + returnto) + self.send_header('Connection', 'close') self.end_headers() + self.wfile.write('') return - if auth_session is not None: - self.auth_token = auth_session.token - - product_endpoint, path = routing.split_client_GET_request(self.path) - if product_endpoint is not None and product_endpoint != '': # Route the user if there is a product endpoint in the request. diff --git a/libcodechecker/version.py b/libcodechecker/version.py index 68c7d02b01..bcdca06f9c 100644 --- a/libcodechecker/version.py +++ b/libcodechecker/version.py @@ -10,7 +10,7 @@ # The name of the cookie which contains the user's authentication session's # token. -SESSION_COOKIE_NAME = "__ccPrivilegedAccessToken" +SESSION_COOKIE_NAME = '__ccPrivilegedAccessToken' # The newest supported minor version (value) for each supported major version # (key) in this particular build. diff --git a/www/login.html b/www/login.html new file mode 100644 index 0000000000..f5f5373586 --- /dev/null +++ b/www/login.html @@ -0,0 +1,63 @@ + + + + CodeChecker + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/scripts/codecheckerviewer/HeaderPane.js b/www/scripts/codecheckerviewer/HeaderPane.js index 38f038c21e..9d0c3d5862 100644 --- a/www/scripts/codecheckerviewer/HeaderPane.js +++ b/www/scripts/codecheckerviewer/HeaderPane.js @@ -6,16 +6,18 @@ define([ 'dojo/_base/declare', - 'dojo/topic', + 'dojo/cookie', 'dojo/dom-construct', + 'dojo/topic', + 'dijit/form/Button', 'dijit/layout/ContentPane', 'dijit/popup', 'dijit/TooltipDialog', 'codechecker/hashHelper', 'codechecker/HeaderMenu', 'codechecker/util'], -function (declare, topic, dom, ContentPane, popup, TooltipDialog, hashHelper, - HeaderMenu, util) { +function (declare, cookie, dom, topic, Button, ContentPane, popup, + TooltipDialog, hashHelper, HeaderMenu, util) { return declare(ContentPane, { postCreate : function () { this.inherited(arguments); @@ -62,6 +64,29 @@ function (declare, topic, dom, ContentPane, popup, TooltipDialog, hashHelper, }, profileMenu); dom.create('span', { class : 'user-name', innerHTML : user}, header); + var logoutButton = new Button({ + class : 'logout-btn', + label : 'Log out', + onClick : function () { + try { + var logoutResult = CC_AUTH_SERVICE.destroySession(); + + if (logoutResult) { + cookie(CC_AUTH_COOKIE_NAME, 'LOGGED_OUT', + { path : '/', expires : -1 }); + + // Redirect the user to the homepage after a successful logout. + window.location.reload(true); + } else { + console.warn("Server rejected logout."); + } + } catch (exc) { + console.error("Logout failed.", exc); + } + } + }); + dom.place(logoutButton.domNode, profileMenu); + //--- Permissions ---// var filter = new CC_AUTH_OBJECTS.PermissionFilter({ given : true }); @@ -133,7 +158,7 @@ function (declare, topic, dom, ContentPane, popup, TooltipDialog, hashHelper, var headerMenuButton = new HeaderMenu({ class : 'main-menu-button', - iconClass : 'dijitIconFunction', + iconClass : 'dijitIconFunction' }); dom.place(headerMenuButton.domNode, this._headerMenu); diff --git a/www/scripts/login/login.js b/www/scripts/login/login.js new file mode 100644 index 0000000000..3379f37be8 --- /dev/null +++ b/www/scripts/login/login.js @@ -0,0 +1,239 @@ +// ------------------------------------------------------------------------- +// The CodeChecker Infrastructure +// This file is distributed under the University of Illinois Open Source +// License. See LICENSE.TXT for details. +// ------------------------------------------------------------------------- + +define([ + 'dojo/_base/declare', + 'dojo/cookie', + 'dojo/dom', + 'dojo/dom-construct', + 'dojo/dom-class', + 'dojo/keys', + 'dojo/on', + 'dijit/form/Button', + 'dijit/form/TextBox', + 'dijit/layout/BorderContainer', + 'dijit/layout/ContentPane', + 'dojox/widget/Standby', + 'codechecker/MessagePane', + 'codechecker/hashHelper'], +function (declare, cookie, dom, domConstruct, domClass, keys, on, Button, + TextBox, BorderContainer, ContentPane, Standby, MessagePane, hash) { + + // A stripped-down version of the "normal" CodeChecker GUI header, tailored + // for a lightweight login window. + var HeaderPane = declare(ContentPane, { + postCreate : function () { + this.inherited(arguments); + + //--- Logo ---// + + var logoContainer = domConstruct.create('div', { + id : 'logo-container' + }, this.domNode); + + var logo = domConstruct.create('span', { id : 'logo' }, logoContainer); + + var logoText = domConstruct.create('div', { + id : 'logo-text', + innerHTML : 'CodeChecker' + }, logoContainer); + } + }); + + var LoginPane = declare(ContentPane, { + _doLogin : function() { + var that = this; + + this.set('isAlreadyLoggingIn', true); + this._standBy.show(); + + CC_AUTH_SERVICE.performLogin( + 'Username:Password', this.txtUser.value + ':' + this.txtPass.value, + function (sessionToken) { + domClass.add(that._mbox.domNode, 'mbox-success'); + that._mbox.show("Successfully logged in!", ''); + + // Set the cookie in the browser. + cookie(CC_AUTH_COOKIE_NAME, sessionToken, { path : '/' }); + + var returnTo = hash.getState('returnto') || ''; + window.location = window.location.origin + '/' + returnTo; + }).error(function (jsReq, status, exc) { + if (status === "parsererror") { + that._standBy.hide(); + domClass.add(that._mbox.domNode, 'mbox-error'); + that._mbox.show("Failed to log in!", exc.message); + + that.txtPass.set('value', ''); + that.txtPass.focus(); + that.set('isAlreadyLoggingIn', false); + } + }); + }, + + constructor : function() { + this._mbox = new MessagePane({ + class : 'mbox' + }); + + this.txtUser = new TextBox({ + class : 'form-input', + name : 'username' + }); + + this.txtPass = new TextBox({ + class : 'form-input', + name : 'password', + type : 'password' + }); + + var that = this; + this.btnSubmit = new Button({ + label : "Login", + onClick : function () { + if (!that.get('isAlreadyLoggingIn')) + that._doLogin(); + } + }); + }, + + postCreate : function() { + this.set('isAlreadyLoggingIn', false); + + this.addChild(this._mbox); + this._mbox.hide(); + + this._standBy = new Standby({ + color : '#ffffff', + target : this.domNode, + duration : 0 + }); + this.addChild(this._standBy); + + var authParams = CC_AUTH_SERVICE.getAuthParameters(); + + if (!authParams.requiresAuthentication || authParams.sessionStillActive) { + domClass.add(this._mbox.domNode, 'mbox-success'); + + var message = ''; + if (!authParams.requiresAuthentication) + message = "This server allows anonymous access."; + else if (authParams.sessionStillActive) + message = "You are already logged in."; + this._mbox.show("No authentication required.", message); + + var returnTo = hash.getState('returnto') || ''; + window.location = window.location.origin + '/' + returnTo; + } else { + var authMethods = CC_AUTH_SERVICE.getAcceptedAuthMethods(); + if (!authMethods.includes('Username:Password')) { + domClass.add(this._mbox.domNode, 'mbox-error'); + this._mbox.show("Server rejects username-password authentication!", + "This login form cannot be used."); + } else { + var cntPrompt = domConstruct.create('div', { + class : 'formElement' + }, this.containerNode); + var lblPrompt = domConstruct.create('span', { + class : 'login-prompt', + style : 'width: 100%', + innerHTML : this.loginPrompt + }, cntPrompt); + + // Render the login dialog's controls. + + var cntUser = domConstruct.create('div', { + class : 'formElement' + }, this.containerNode); + var lblUser = domConstruct.create('label', { + class : 'formLabel bold', + innerHTML : "Username: ", + for : 'username' + }, cntUser); + domConstruct.place(this.txtUser.domNode, cntUser); + + var cntPass = domConstruct.create('div', { + class : 'formElement' + }, this.containerNode); + var lblPass = domConstruct.create('label', { + class : 'formLabel bold', + innerHTML : "Password: ", + for : 'password' + }, cntPass); + domConstruct.place(this.txtPass.domNode, cntPass); + + var cntLogin = domConstruct.create('div', { + class : 'formElement' + }, this.containerNode); + domConstruct.place(this.btnSubmit.domNode, cntLogin); + + var that = this; + function keypressHandler(evt) { + if (!that.get('isAlreadyLoggingIn') && + evt.keyCode === keys.ENTER) { + that.btnSubmit.focus(); + that._doLogin(); + } + } + on(this.txtUser.domNode, 'keypress', keypressHandler); + on(this.txtPass.domNode, 'keypress', keypressHandler); + on(this.btnSubmit.domNode, 'keypress', keypressHandler); + } + } + } + }); + + return function () { + + //---------------------------- Global objects ----------------------------// + + CC_AUTH_SERVICE = + new codeCheckerAuthentication_v6.codeCheckerAuthenticationClient( + new Thrift.TJSONProtocol( + new Thrift.Transport("/v" + CC_API_VERSION + "/Authentication"))); + + CC_AUTH_OBJECTS = codeCheckerAuthentication_v6; + + //----------------------------- Main layout ------------------------------// + + var layout = new BorderContainer({ id : 'mainLayout' }); + + var headerPane = new HeaderPane({ + id : 'headerPane', + region : 'top' + }); + layout.addChild(headerPane); + + //--- Center panel ---// + + var that = this; + + this.loginPane = new LoginPane({ + region : 'center', + + // TODO: Extend the API so that custom messages can be shown here. + loginPrompt : "Accessing this CodeChecker server requires authentication!" + }); + + var loginContainer = new ContentPane({ + region : 'center', + postCreate : function () { + var smallerContainer = domConstruct.create('div', { + id : 'login-form' + }, this.containerNode); + + domConstruct.place(that.loginPane.domNode, smallerContainer); + } + }); + + layout.addChild(loginContainer); + + //--- Init page ---// + + document.body.appendChild(layout.domNode); + layout.startup(); + }; +}); diff --git a/www/scripts/version.js b/www/scripts/version.js index b08a4e3cea..a99814a119 100644 --- a/www/scripts/version.js +++ b/www/scripts/version.js @@ -1 +1,2 @@ CC_API_VERSION = '6.6'; +CC_AUTH_COOKIE_NAME = '__ccPrivilegedAccessToken'; diff --git a/www/style/codecheckerviewer.css b/www/style/codecheckerviewer.css index 6ceafd3d11..e247119f34 100644 --- a/www/style/codecheckerviewer.css +++ b/www/style/codecheckerviewer.css @@ -86,7 +86,6 @@ html, body { } #profile-menu .header { - border-bottom: 1px solid #b5bcc7; padding-bottom: 5px; } @@ -95,7 +94,17 @@ html, body { color: #357ea7; } +#profile-menu .logout-btn { + padding-bottom: 5px; +} + +/** Remove orange outline from tooltip button. **/ +#profile-menu .logout-btn .dijitButtonContents { + outline: none; +} + #profile-menu .permission-title { + border-top: 1px solid #b5bcc7; padding: 5px 0px; } diff --git a/www/style/login.css b/www/style/login.css new file mode 100644 index 0000000000..88dd787aab --- /dev/null +++ b/www/style/login.css @@ -0,0 +1,26 @@ +#login-form { + display: block; + position: relative; + top: 25%; + margin: 0 auto; + width: 20%; + background-color: #edf4fa; + border: 1px solid; + border-color: #e5e6e9 #dfe0e4 #d0d1d5; + border-radius: 5px; +} + +#login-form .mbox.mbox-error { + background-color: white; +} + +#login-form .login-prompt { + font-weight: bold; + font-size: 12pt; + display: inline-block; + margin-bottom: 18px; +} + +#login-form .formElement .form-input { + width: 100%; +}