From 722c3bd3698d5e04dc2b5929fce1d81751f89103 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 4 Mar 2026 16:44:40 -0500 Subject: [PATCH] Prototype: OTP auth, styleguide overhaul, login/session rework Replace password reset with email OTP verification flow. Add auth_otp module, OTP templates, and email delivery. Expand styleguide CSS with full component library. Rework login, register, and admin views for cookie sessions. Remove legacy 2FA templates and password reset flow. Update SSH wrappers, forms, validators, and middleware. --- configs/development.ini | 20 +- package-lock.json | 9 - rhodecode/apps/_base/__init__.py | 72 +- rhodecode/apps/admin/views/repo_groups.py | 104 +- rhodecode/apps/admin/views/users.py | 2 + rhodecode/apps/login/__init__.py | 67 +- rhodecode/apps/login/views.py | 645 ++- rhodecode/apps/my_account/views/my_account.py | 23 +- .../repository/views/repo_pull_requests.py | 2 + .../views/repo_settings_advanced.py | 2 + .../apps/ssh_support/lib/ssh_wrapper_v1.py | 5 +- .../apps/ssh_support/lib/ssh_wrapper_v2.py | 5 +- rhodecode/config/middleware.py | 2 +- rhodecode/config/patches.py | 12 - rhodecode/lib/auth_otp.py | 58 + rhodecode/lib/middleware/vcs.py | 9 +- rhodecode/model/db.py | 47 + rhodecode/model/forms.py | 67 +- rhodecode/model/notification.py | 6 +- rhodecode/model/validators.py | 31 - rhodecode/public/css/styleguide.css | 3664 ++++++++++++++--- .../js/rhodecode/base/keyboard-bindings.js | 8 +- rhodecode/templates/admin/main.mako | 80 +- .../admin/my_account/my_account.mako | 2 - .../admin/repo_groups/repo_group_add.mako | 2 +- .../templates/admin/repos/repo_add_base.mako | 4 +- rhodecode/templates/base/base.mako | 339 +- rhodecode/templates/base/root.mako | 10 +- rhodecode/templates/configure_2fa.mako | 90 - .../templates/email_templates/otp_code.mako | 25 + .../email_templates/password_reset.mako | 36 - .../password_reset_confirmation.mako | 30 - rhodecode/templates/login.mako | 64 +- rhodecode/templates/otp_verify.mako | 97 + rhodecode/templates/password_reset.mako | 103 - rhodecode/templates/register.mako | 74 +- rhodecode/templates/sudo_otp_verify.mako | 65 + rhodecode/templates/verify_2fa.mako | 54 - 38 files changed, 4100 insertions(+), 1835 deletions(-) create mode 100644 rhodecode/lib/auth_otp.py delete mode 100644 rhodecode/templates/configure_2fa.mako create mode 100644 rhodecode/templates/email_templates/otp_code.mako delete mode 100644 rhodecode/templates/email_templates/password_reset.mako delete mode 100644 rhodecode/templates/email_templates/password_reset_confirmation.mako create mode 100644 rhodecode/templates/otp_verify.mako delete mode 100644 rhodecode/templates/password_reset.mako create mode 100644 rhodecode/templates/sudo_otp_verify.mako delete mode 100644 rhodecode/templates/verify_2fa.mako diff --git a/configs/development.ini b/configs/development.ini index b44dd24e..a5880211 100644 --- a/configs/development.ini +++ b/configs/development.ini @@ -149,7 +149,7 @@ app.base_url = http://rhodecode.local app.service_api.host = http://rhodecode.local:10020 ; Secret for Service API authentication. -app.service_api.token = +app.service_api.token = oFK71R0pWjdtSukV0ajcnfVxe4j2ExWc7otEKvRPaks ; Unique application ID. Should be a random unique string for security. app_instance_uuid = rc-production @@ -254,7 +254,7 @@ auth_ret_code_detection = false lock_ret_code = 423 ; Filesystem location were repositories should be stored -repo_store.path = /var/opt/rhodecode_repo_store +repo_store.path = /home/fox/git/rhodecode-enterprise-ce/.dev/repo_store ; allows to setup custom hooks in settings page allow_custom_hooks_settings = true @@ -309,7 +309,7 @@ labs_settings_active = true ; NOTE: this setting IS DEPRECATED: ; file_store.storage_path = X -> use `file_store.filesystem_v2.storage_path = X` instead ; path to store the uploaded binaries and artifacts -#file_store.storage_path = /var/opt/rhodecode_data/file_store +#file_store.storage_path = /home/fox/git/rhodecode-enterprise-ce/.dev/data/file_store ; Artifacts file-store, is used to store comment attachments and artifacts uploads. ; file_store backend type: filesystem_v1, filesystem_v2 or objectstore (s3-based) are available as options @@ -319,10 +319,10 @@ labs_settings_active = true #file_store.backend.type = filesystem_v2 ; filesystem options... -#file_store.filesystem_v1.storage_path = /var/opt/rhodecode_data/artifacts_file_store +#file_store.filesystem_v1.storage_path = /home/fox/git/rhodecode-enterprise-ce/.dev/data/artifacts_file_store ; filesystem_v2 options... -#file_store.filesystem_v2.storage_path = /var/opt/rhodecode_data/artifacts_file_store +#file_store.filesystem_v2.storage_path = /home/fox/git/rhodecode-enterprise-ce/.dev/data/artifacts_file_store #file_store.filesystem_v2.shards = 8 ; objectstore options... @@ -389,7 +389,7 @@ archive_cache.objectstore.retry_attempts = 10 ; and served from the cache during subsequent requests for the same archive of ; the repository. This path is important to be shared across filesystems and with ; RhodeCode and vcsserver -archive_cache.filesystem.store_dir = /var/opt/rhodecode_data/archive_cache +archive_cache.filesystem.store_dir = /home/fox/git/rhodecode-enterprise-ce/.dev/data/archive_cache ; The limit in GB sets how much data we cache before recycling last used, defaults to 10 gb archive_cache.filesystem.cache_size_gb = 1 @@ -455,7 +455,7 @@ celery.task_always_eager = false ; Default cache dir for caches. Putting this into a ramdisk can boost performance. ; eg. /tmpfs/data_ramdisk, however this directory might require large amount of space -cache_dir = /var/opt/rhodecode_data +cache_dir = /home/fox/git/rhodecode-enterprise-ce/.dev/data ; ********************************************* ; `sql_cache_short` cache for heavy SQL queries @@ -644,7 +644,7 @@ channelstream.server = channelstream:8000 ; For development, comment this out to use auto-generated proxy URL ;channelstream.ws_url = ws://rhodecode.yourserver.com/_channelstream channelstream.secret = ENV_GENERATED -channelstream.history.location = /var/opt/rhodecode_data/channelstream_history +channelstream.history.location = /home/fox/git/rhodecode-enterprise-ce/.dev/data/channelstream_history ; Internal application path that Javascript uses to connect into. ; If you use proxy-prefix the prefix should be added before /_channelstream @@ -729,10 +729,10 @@ vcs.connection_timeout = 3600 vcs.methods.cache = true ; Filesystem location where Git lfs objects should be stored -vcs.git.lfs.storage_location = /var/opt/rhodecode_repo_store/.cache/git_lfs_store +vcs.git.lfs.storage_location = /home/fox/git/rhodecode-enterprise-ce/.dev/repo_store/.cache/git_lfs_store ; Filesystem location where Mercurial largefile objects should be stored -vcs.hg.largefiles.storage_location = /var/opt/rhodecode_repo_store/.cache/hg_largefiles_store +vcs.hg.largefiles.storage_location = /home/fox/git/rhodecode-enterprise-ce/.dev/repo_store/.cache/hg_largefiles_store ; #################################################### ; Subversion proxy support (mod_dav_svn) diff --git a/package-lock.json b/package-lock.json index a86858d7..67914fb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,7 +79,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1994,7 +1993,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2021,7 +2019,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2249,7 +2246,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3438,7 +3434,6 @@ "integrity": "sha512-/ABUy3gYWu5iBmrUSRBP97JLpQUm0GgVveDCp6t3yRNIoltIYw7rEj3g5y1o2PGPR2vfTRGa7WC/LZHLTXnEzA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "dateformat": "~4.6.2", "eventemitter2": "~0.4.13", @@ -5103,7 +5098,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5829,7 +5823,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -6107,7 +6100,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -6157,7 +6149,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/rhodecode/apps/_base/__init__.py b/rhodecode/apps/_base/__init__.py index f7f7e18f..91ecb717 100644 --- a/rhodecode/apps/_base/__init__.py +++ b/rhodecode/apps/_base/__init__.py @@ -129,9 +129,11 @@ class TemplateArgs(StrictAttributeDict): class BaseAppView(object): DONT_CHECKOUT_VIEWS = ["channelstream_connect", "ops_ping"] - EXTRA_VIEWS_TO_IGNORE = ["login", "register", "logout"] - SETUP_2FA_VIEW = "setup_2fa" - VERIFY_2FA_VIEW = "check_2fa" + EXTRA_VIEWS_TO_IGNORE = [ + "login", "register", "logout", + "login_otp_verify", + "sudo_otp_verify", "resend_otp", + ] def __init__(self, context, request): self.request = request @@ -147,70 +149,6 @@ class BaseAppView(object): self._rhodecode_user = request.user # auth user self._rhodecode_db_user = self._rhodecode_user.get_instance() self.user_data = self._rhodecode_db_user.user_data if self._rhodecode_db_user else {} - self._maybe_needs_password_change(request.matched_route.name, self._rhodecode_db_user) - self._maybe_needs_2fa_configuration(request.matched_route.name, self._rhodecode_db_user) - self._maybe_needs_2fa_check(request.matched_route.name, self._rhodecode_db_user) - - def _maybe_needs_password_change(self, view_name, user_obj): - if view_name in self.DONT_CHECKOUT_VIEWS: - return - - log.debug("Checking if user %s needs password change on view %s", user_obj, view_name) - - skip_user_views = [ - "logout", - "login", - "check_2fa", - "my_account_password", - "my_account_password_update", - ] - - if not user_obj: - return - - if user_obj.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER, User.SECURITY_USER]: - return - - now = time.time() - should_change = self.user_data.get("force_password_change") - change_after = safe_int(should_change) or 0 - if should_change and now > change_after: - log.debug("User %s requires password change", user_obj) - h.flash( - "You are required to change your password", - "warning", - ignore_duplicate=True, - ) - - if view_name not in skip_user_views: - raise HTTPFound(self.request.route_path("my_account_password")) - - def _maybe_needs_2fa_configuration(self, view_name, user_obj): - if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE: - return - - if not user_obj: - return - - if user_obj.needs_2fa_configure and view_name != self.SETUP_2FA_VIEW: - h.flash( - "You are required to configure 2FA", - "warning", - ignore_duplicate=False, - ) - # Special case for users created "on the fly" (ldap case for new user) - user_obj.check_2fa_required = False - raise HTTPFound(self.request.route_path(self.SETUP_2FA_VIEW)) - - def _maybe_needs_2fa_check(self, view_name, user_obj): - if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE: - return - - if not user_obj: - return - - if user_obj.check_2fa_required and view_name != self.VERIFY_2FA_VIEW: - raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW)) def _log_creation_exception(self, e, repo_name): _ = self.request.translate diff --git a/rhodecode/apps/admin/views/repo_groups.py b/rhodecode/apps/admin/views/repo_groups.py index 42a00099..d5c657ce 100644 --- a/rhodecode/apps/admin/views/repo_groups.py +++ b/rhodecode/apps/admin/views/repo_groups.py @@ -34,6 +34,7 @@ from rhodecode.apps._base import BaseAppView, DataGridAppView from rhodecode.lib.auth import LoginRequired, CSRFRequired, NotAnonymous, HasPermissionAny, HasRepoGroupPermissionAny from rhodecode.lib import helpers as h, audit_logger from rhodecode.lib.str_utils import safe_int, safe_str +from rhodecode.lib.utils import repo_name_slug from rhodecode.model.forms import RepoGroupForm from rhodecode.model.permission import PermissionModel from rhodecode.model.repo_group import RepoGroupModel @@ -283,18 +284,23 @@ class AdminRepoGroupsView(BaseAppView, DataGridAppView): can_create = self._can_create_repo_group(parent_group_id) self._load_form_data(c) - # permissions for can create group based on parent_id are checked - # here in the Form + + raw_group_name = self.request.POST.get("group_name", "").strip() + + # Path-based creation: if the name contains "/" we create + # intermediate groups automatically (e.g. "org/team/project") + if "/" in raw_group_name: + return self._create_repo_group_path(c, raw_group_name, parent_group_id) + + # Standard single-group creation via formencode available_groups = list(map(lambda k: safe_str(k[0]), c.repo_groups)) repo_group_form = RepoGroupForm( self.request.translate, available_groups=available_groups, can_create_in_root=can_create )() - repo_group_name = self.request.POST.get("group_name") try: owner = self._rhodecode_user form_result = repo_group_form.to_python(dict(self.request.POST)) - copy_permissions = form_result.get("group_copy_permissions") repo_group = RepoGroupModel().create( group_name=form_result["group_name_full"], group_description=form_result["group_description"], @@ -332,9 +338,97 @@ class AdminRepoGroupsView(BaseAppView, DataGridAppView): return Response(html) except Exception: log.exception("Exception during creation of repository group") - h.flash(_("Error occurred during creation of repository group %s") % repo_group_name, category="error") + h.flash(_("Error occurred during creation of repository group %s") % raw_group_name, category="error") raise HTTPFound(h.route_path("home")) PermissionModel().trigger_permission_flush() raise HTTPFound(h.route_path("repo_group_home", repo_group_name=form_result["group_name_full"])) + + def _create_repo_group_path(self, c, path_input, parent_group_id): + """ + Create a chain of nested repo groups from a slash-separated path. + E.g. "org/team/project" creates org, org/team, org/team/project + (skipping any that already exist). + """ + _ = self.request.translate + owner = self._rhodecode_user + + # Resolve parent group prefix + parent_prefix = "" + if parent_group_id and parent_group_id != -1: + parent_group = RepoGroup.get(parent_group_id) + if parent_group: + parent_prefix = parent_group.group_name + "/" + + # Split path into segments and slugify each one + segments = [repo_name_slug(s.strip()) for s in path_input.split("/") if s.strip()] + if not segments: + h.flash(_("Invalid group path"), category="error") + raise HTTPFound(h.route_path("repo_group_new")) + + copy_permissions = self.request.POST.get("group_copy_permissions") == "True" + description = self.request.POST.get("group_description", "") + created_groups = [] + final_group_name = None + + try: + for i, segment in enumerate(segments): + full_name = parent_prefix + "/".join(segments[: i + 1]) + + # Skip if group already exists + existing = RepoGroup.get_by_group_name(full_name) + if existing: + log.debug("Group %s already exists, skipping", full_name) + continue + + # Only apply description to the final group + grp_description = description if i == len(segments) - 1 else segment + + repo_group = RepoGroupModel().create( + group_name=full_name, + group_description=grp_description, + owner=owner.user_id, + copy_permissions=copy_permissions, + ) + Session().flush() + + audit_logger.store_web( + "repo_group.create", + action_data={"data": repo_group.get_api_data()}, + user=self._rhodecode_user, + ) + created_groups.append(full_name) + final_group_name = full_name + + Session().commit() + + if not final_group_name: + # All groups already existed; redirect to the deepest one + final_group_name = parent_prefix + "/".join(segments) + + if len(created_groups) == 1: + msg = _("Created repository group %s") % h.link_to( + created_groups[0], + h.route_path("repo_group_home", repo_group_name=created_groups[0]), + ) + elif created_groups: + msg = _("Created %d repository groups: %s") % ( + len(created_groups), + ", ".join(created_groups), + ) + else: + msg = _("All groups already exist") + + h.flash(h.literal(msg), category="success") + + except Exception: + log.exception("Exception during path-based repository group creation") + h.flash( + _("Error creating repository group path: %s") % path_input, + category="error", + ) + raise HTTPFound(h.route_path("home")) + + PermissionModel().trigger_permission_flush() + raise HTTPFound(h.route_path("repo_group_home", repo_group_name=final_group_name)) diff --git a/rhodecode/apps/admin/views/users.py b/rhodecode/apps/admin/views/users.py index 92a1cf8c..8d9f957b 100644 --- a/rhodecode/apps/admin/views/users.py +++ b/rhodecode/apps/admin/views/users.py @@ -45,6 +45,7 @@ from rhodecode.lib.exceptions import ( ) from rhodecode.lib import ext_json from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator, CSRFRequired +from rhodecode.lib.auth_otp import SudoOTPRequired from rhodecode.lib import helpers as h from rhodecode.lib.helpers import SqlPage from rhodecode.lib.utils2 import safe_int, safe_str, AttributeDict @@ -360,6 +361,7 @@ class UsersView(UserAppView): @LoginRequired() @HasPermissionAllDecorator("hg.admin") @CSRFRequired() + @SudoOTPRequired def user_delete(self): _ = self.request.translate c = self.load_default_context() diff --git a/rhodecode/apps/login/__init__.py b/rhodecode/apps/login/__init__.py index 577774c0..86391162 100644 --- a/rhodecode/apps/login/__init__.py +++ b/rhodecode/apps/login/__init__.py @@ -35,60 +35,61 @@ def includeme(config): renderer="rhodecode:templates/login.mako", ) + config.add_route(name="login_otp_verify", pattern=ADMIN_PREFIX + "/login/verify") + config.add_view( + LoginView, + attr="login_otp_verify", + route_name="login_otp_verify", + request_method="GET", + renderer="rhodecode:templates/otp_verify.mako", + ) + config.add_view( + LoginView, + attr="login_otp_verify_post", + route_name="login_otp_verify", + request_method="POST", + renderer="rhodecode:templates/otp_verify.mako", + ) + + config.add_route(name="resend_otp", pattern=ADMIN_PREFIX + "/otp/resend") + config.add_view( + LoginView, + attr="resend_otp", + route_name="resend_otp", + request_method="POST", + renderer="json", + ) + config.add_route(name="logout", pattern=ADMIN_PREFIX + "/logout") config.add_view(LoginView, attr="logout", route_name="logout", request_method="POST") + # Register redirects to login (unified flow) config.add_route(name="register", pattern=ADMIN_PREFIX + "/register") config.add_view( LoginView, attr="register", route_name="register", request_method="GET", - renderer="rhodecode:templates/register.mako", ) config.add_view( LoginView, - attr="register_post", + attr="register", route_name="register", request_method="POST", - renderer="rhodecode:templates/register.mako", ) - config.add_route(name="reset_password", pattern=ADMIN_PREFIX + "/password_reset") + config.add_route(name="sudo_otp_verify", pattern=ADMIN_PREFIX + "/sudo/verify") config.add_view( LoginView, - attr="password_reset", - route_name="reset_password", - request_method=("GET", "POST"), - renderer="rhodecode:templates/password_reset.mako", - ) - - config.add_route(name="reset_password_confirmation", pattern=ADMIN_PREFIX + "/password_reset_confirmation") - config.add_view( - LoginView, attr="password_reset_confirmation", route_name="reset_password_confirmation", request_method="GET" - ) - - config.add_route(name="setup_2fa", pattern=ADMIN_PREFIX + "/setup_2fa") - config.add_view( - LoginView, - attr="setup_2fa", - route_name="setup_2fa", - request_method=["GET", "POST"], - renderer="rhodecode:templates/configure_2fa.mako", - ) - - config.add_route(name="check_2fa", pattern=ADMIN_PREFIX + "/check_2fa") - config.add_view( - LoginView, - attr="verify_2fa", - route_name="check_2fa", + attr="sudo_otp_verify", + route_name="sudo_otp_verify", request_method="GET", - renderer="rhodecode:templates/verify_2fa.mako", + renderer="rhodecode:templates/sudo_otp_verify.mako", ) config.add_view( LoginView, - attr="verify_2fa", - route_name="check_2fa", + attr="sudo_otp_verify_post", + route_name="sudo_otp_verify", request_method="POST", - renderer="rhodecode:templates/verify_2fa.mako", + renderer="rhodecode:templates/sudo_otp_verify.mako", ) diff --git a/rhodecode/apps/login/views.py b/rhodecode/apps/login/views.py index 06a041ff..0a36bbbb 100644 --- a/rhodecode/apps/login/views.py +++ b/rhodecode/apps/login/views.py @@ -16,9 +16,10 @@ # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ +import hashlib +import re +import secrets import time -import pyotp -import qrcode import dataclasses import datetime import formencode @@ -26,17 +27,12 @@ import formencode.htmlfill import logging import urllib.parse import requests -from io import BytesIO -from base64 import b64encode -from pyramid.renderers import render -from pyramid.response import Response from pyramid.httpexceptions import HTTPFound import rhodecode from rhodecode.apps._base import BaseAppView from rhodecode.authentication.base import authenticate, HTTP_TYPE -from rhodecode.authentication.plugins import auth_rhodecode from rhodecode.events import UserRegistered, trigger from rhodecode.lib import helpers as h from rhodecode.lib import audit_logger @@ -44,10 +40,9 @@ from rhodecode.lib.auth import AuthUser, HasPermissionAnyDecorator, CSRFRequired from rhodecode.lib.base import get_ip_addr from rhodecode.lib.exceptions import UserCreationError from rhodecode.lib.utils2 import safe_str -from rhodecode.model.db import User, UserApiKeys -from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm, TOTPForm +from rhodecode.model.db import User +from rhodecode.model.forms import LoginForm, OTPVerifyForm from rhodecode.model.meta import Session -from rhodecode.model.auth_token import AuthTokenModel from rhodecode.model.settings import SettingsModel from rhodecode.model.user import UserModel from rhodecode.translation import _ @@ -84,15 +79,8 @@ def store_user_in_session(session, user_identifier, remember=False): safe_cs["password"] = "****" log.info("user %s is now authenticated and stored in session, session attrs %s", user_identifier, safe_cs) - # dumps session attrs back to cookie - if hasattr(session, '_update_cookie_out'): - session._update_cookie_out() - # we set new cookie - headers = None - if session.request["set_cookie"]: - # send set-cookie headers back to response to update cookie - headers = [("Set-Cookie", session.request["cookie_out"])] - return headers + # Cookie is set automatically by Pyramid's session response callback + return None def get_came_from(request): @@ -114,6 +102,21 @@ def get_came_from(request): return came_from or default_came_from +def _derive_username_from_email(email): + local_part = email.split("@")[0] + # sanitize: keep only alphanumeric, dash, underscore, dot + username = re.sub(r'[^a-zA-Z0-9._-]', '_', local_part).lower() + if not username: + username = 'user' + # ensure unique + base = username + suffix = 0 + while User.get_by_username(username): + suffix += 1 + username = f'{base}_{suffix}' + return username + + class LoginView(BaseAppView): def load_default_context(self): c = self._get_local_tmpl_context() @@ -144,6 +147,28 @@ class LoginView(BaseAppView): return captcha_status, captcha_message + def _send_otp_email(self, email, code, purpose): + from rhodecode.lib.celerylib import tasks, run_task + from rhodecode.model.notification import EmailNotificationModel + + import rhodecode + smtp_server = rhodecode.CONFIG.get('smtp_server') + log_code = code if not smtp_server else 'X' * len(code) + log.info('[OTP] code=%s email=%s purpose=%s', log_code, email, purpose) + + email_kwargs = { + 'otp_code': code, + 'purpose': purpose, + } + + (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email( + EmailNotificationModel.TYPE_OTP_CODE, **email_kwargs + ) + + run_task(tasks.send_email, [email], subject, email_body_plaintext, email_body) + + # ---- Login Step 1: Email ---- + def login(self): c = self.load_default_context() auth_user = self._rhodecode_user @@ -171,46 +196,255 @@ class LoginView(BaseAppView): login_form = LoginForm(self.request.translate)() try: - self.session.invalidate() form_result = login_form.to_python(self.request.POST) - # form checks for username/password, now we're authenticated - username = form_result["username"] - if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa: - user.check_2fa_required = True + email = form_result["email"] + remember = form_result["remember"] - headers = store_user_in_session(self.session, user_identifier=username, remember=form_result["remember"]) - log.debug('Redirecting to "%s" after login.', c.came_from) + # Throttle to prevent enumeration + time.sleep(1) - audit_user = audit_logger.UserWrap( - username=self.request.POST.get("username"), ip_addr=self.request.remote_addr - ) - action_data = {"user_agent": self.request.user_agent} - audit_logger.store_web("user.login.success", action_data=action_data, user=audit_user, commit=True) + user = User.get_by_email(email) + if user and user.active: + # Existing active user — generate OTP on user object + code = user.generate_otp("login") + Session().commit() + self._send_otp_email(email, code, "login") + self.session["otp_is_new_user"] = False + elif user and not user.active: + # Inactive user — don't send OTP but still redirect (anti-enumeration) + log.debug("login OTP request for inactive email %s", email) + self.session["otp_is_new_user"] = False + else: + # Unknown email — auto-register if allowed + default_user = User.get_default_user() + perms = default_user.AuthUser().permissions["global"] + registration_allowed = ( + "hg.register.auto_activate" in perms + or "hg.register.manual_activate" in perms + ) + + if registration_allowed: + # Generate session-based OTP for new user registration + code = ''.join(str(secrets.randbelow(10)) for _ in range(6)) + code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest() + self.session["otp_hash"] = code_hash + self.session["otp_created_at"] = time.time() + self.session["otp_attempts"] = 0 + self.session["otp_is_new_user"] = True + self._send_otp_email(email, code, "login") + else: + log.debug("login OTP request for unknown email %s, registration disabled", email) + self.session["otp_is_new_user"] = False + + # Always store in session and redirect (prevent email enumeration) + self.session["otp_email"] = email + self.session["otp_remember"] = remember + self.session["otp_came_from"] = c.came_from + if hasattr(self.session, 'save'): + self.session.save() + + raise HTTPFound(self.request.route_path("login_otp_verify")) - raise HTTPFound(c.came_from, headers=headers) except formencode.Invalid as errors: defaults = errors.value - # remove password from filling in form again - defaults.pop("password", None) render_ctx = { "errors": errors.error_dict, "defaults": defaults, } - - audit_user = audit_logger.UserWrap( - username=self.request.POST.get("username"), ip_addr=self.request.remote_addr - ) - action_data = {"user_agent": self.request.user_agent} - audit_logger.store_web("user.login.failure", action_data=action_data, user=audit_user, commit=True) return self._get_template_context(c, **render_ctx) - except UserCreationError as e: - # headers auth or other auth functions that create users on - # the fly can throw this exception signaling that there's issue - # with user creation, explanation should be provided in - # Exception itself - h.flash(e, category="error") - return self._get_template_context(c) + # ---- Login Step 2: OTP Verify ---- + + def login_otp_verify(self): + c = self.load_default_context() + email = self.session.get("otp_email") + if not email: + raise HTTPFound(self.request.route_path("login")) + return self._get_template_context(c, + verify_route="login_otp_verify", + otp_digits=6, + purpose="login", + otp_email=email, + ) + + def login_otp_verify_post(self): + c = self.load_default_context() + email = self.session.get("otp_email") + is_new_user = self.session.get("otp_is_new_user", False) + log.debug("OTP verify: email=%s is_new_user=%s session_keys=%s", + email, is_new_user, list(self.session.keys())) + if not email: + raise HTTPFound(self.request.route_path("login")) + + otp_form = OTPVerifyForm(self.request.translate, digits=6)() + + try: + form_result = otp_form.to_python(self.request.POST) + code = form_result["otp"] + + if is_new_user: + self._verify_new_user_otp(email, code, form_result) + else: + self._verify_existing_user_otp(email, code, form_result) + + except formencode.Invalid as errors: + defaults = errors.value + render_ctx = { + "errors": errors.error_dict, + "defaults": defaults, + "verify_route": "login_otp_verify", + "otp_digits": 6, + "purpose": "login", + "otp_email": email, + } + return self._get_template_context(c, **render_ctx) + + def _verify_existing_user_otp(self, email, code, form_result): + user = User.get_by_email(email) + if not user or not user.verify_otp(code, "login"): + Session().commit() + error_msg = _("Invalid or expired code. Please try again.") + raise formencode.Invalid(error_msg, form_result, None, + error_dict={"otp": error_msg}) + + Session().commit() + + remember = self.session.get("otp_remember", False) + came_from = self.session.get("otp_came_from", h.route_path("home")) + + # Clear OTP session data + self._clear_otp_session() + + headers = store_user_in_session(self.session, user_identifier=email, remember=remember) + + audit_user = audit_logger.UserWrap( + username=user.username, ip_addr=self.request.remote_addr + ) + action_data = {"user_agent": self.request.user_agent} + audit_logger.store_web("user.login.success", action_data=action_data, user=audit_user, commit=True) + + raise HTTPFound(came_from, headers=headers) + + def _verify_new_user_otp(self, email, code, form_result): + from rhodecode.authentication.plugins import auth_rhodecode + + stored_hash = self.session.get("otp_hash") + created_at = self.session.get("otp_created_at", 0) + attempts = self.session.get("otp_attempts", 0) + + if attempts >= 5: + self._clear_otp_session() + h.flash(_("Too many attempts. Please start over."), category="error") + raise HTTPFound(self.request.route_path("login")) + + if (time.time() - created_at) > 300: + self._clear_otp_session() + h.flash(_("Code expired. Please start over."), category="error") + raise HTTPFound(self.request.route_path("login")) + + self.session["otp_attempts"] = attempts + 1 + if hasattr(self.session, 'save'): + self.session.save() + + code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest() + if code_hash != stored_hash: + error_msg = _("Invalid code. Please try again.") + raise formencode.Invalid(error_msg, form_result, None, + error_dict={"otp": error_msg}) + + # OTP verified — create the user account + auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"] + username = _derive_username_from_email(email) + + # First real user becomes admin (bootstrap) + system_users = {User.DEFAULT_USER, User.GHOST_USER, User.AI_USER, User.SECURITY_USER} + real_user_count = User.query().filter(~User.username.in_(system_users)).count() + is_first_user = real_user_count == 0 + + form_data = { + "username": username, + "password": secrets.token_hex(32), + "email": email, + "firstname": "", + "lastname": "", + "active": True if is_first_user else auto_active, + "admin": is_first_user, + } + + extern_name = auth_rhodecode.RhodeCodeAuthPlugin.uid + extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid + + new_user = UserModel().create_registration(form_data, extern_name=extern_name, extern_type=extern_type) + + action_data = {"data": new_user.get_api_data(), "user_agent": self.request.user_agent} + audit_user = audit_logger.UserWrap( + username=new_user.username, user_id=new_user.user_id, ip_addr=self.request.remote_addr + ) + audit_logger.store_web("user.register", action_data=action_data, user=audit_user) + + event = UserRegistered(user=new_user, session=self.session) + trigger(event) + Session().commit() + + # Clean up OTP session data + self._clear_otp_session() + + if is_first_user or auto_active: + headers = store_user_in_session(self.session, user_identifier=email, remember=False) + h.flash(_("Your account has been created. Welcome!"), category="success") + raise HTTPFound(h.route_path("home"), headers=headers) + else: + h.flash(_("Your account has been created and awaits admin approval."), category="success") + raise HTTPFound(self.request.route_path("login")) + + def _clear_otp_session(self): + for key in list(self.session.keys()): + if key.startswith("otp_"): + self.session.pop(key, None) + if hasattr(self.session, 'save'): + self.session.save() + + # ---- Resend OTP ---- + + def resend_otp(self): + email = self.session.get("otp_email") + if not email: + return {"status": "error", "message": "No pending verification."} + + # Rate limit: one per 60s + last_sent = self.session.get("otp_last_sent", 0) + if time.time() - last_sent < 60: + return {"status": "error", "message": "Please wait before requesting a new code."} + + is_new_user = self.session.get("otp_is_new_user", False) + + if is_new_user: + # New user: regenerate OTP stored in session + code = ''.join(str(secrets.randbelow(10)) for _ in range(6)) + code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest() + self.session["otp_hash"] = code_hash + self.session["otp_created_at"] = time.time() + self.session["otp_attempts"] = 0 + else: + # Existing user: regenerate on the user object + user = User.get_by_email(email) + if user and user.active: + code = user.generate_otp("login") + Session().commit() + else: + # Fake success to prevent enumeration + self.session["otp_last_sent"] = time.time() + if hasattr(self.session, 'save'): + self.session.save() + return {"status": "ok", "message": "Code sent."} + + self._send_otp_email(email, code, "verification") + self.session["otp_last_sent"] = time.time() + if hasattr(self.session, 'save'): + self.session.save() + return {"status": "ok", "message": "Code sent."} + + # ---- Logout ---- @CSRFRequired() def logout(self): @@ -222,296 +456,51 @@ class LoginView(BaseAppView): self.session.delete() return HTTPFound(h.route_path("home")) - @HasPermissionAnyDecorator("hg.admin", "hg.register.auto_activate", "hg.register.manual_activate") + # ---- Registration (redirects to unified login flow) ---- + def register(self, defaults=None, errors=None): + raise HTTPFound(self.request.route_path("login")) + + # ---- Sudo OTP ---- + + @LoginRequired() + @NotAnonymous() + def sudo_otp_verify(self): c = self.load_default_context() - defaults = defaults or {} - errors = errors or {} + return self._get_template_context(c) - settings = SettingsModel().get_all_settings() - register_message = settings.get("rhodecode_register_message") or "" - captcha = self._get_captcha_data() - auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"] + @LoginRequired() + @NotAnonymous() + @CSRFRequired() + def sudo_otp_verify_post(self): + c = self.load_default_context() + user = self._rhodecode_db_user - render_ctx = self._get_template_context(c) - render_ctx.update( - { - "defaults": defaults, - "errors": errors, - "auto_active": auto_active, - "captcha_active": captcha.active, - "captcha_public_key": captcha.public_key, - "register_message": register_message, - } - ) - return render_ctx + otp_form = OTPVerifyForm(self.request.translate, digits=8)() - @HasPermissionAnyDecorator("hg.admin", "hg.register.auto_activate", "hg.register.manual_activate") - def register_post(self): - from rhodecode.authentication.plugins import auth_rhodecode - - self.load_default_context() - captcha = self._get_captcha_data() - auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"] - - extern_name = auth_rhodecode.RhodeCodeAuthPlugin.uid - extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid - - register_form = RegisterForm(self.request.translate)() try: - form_result = register_form.to_python(self.request.POST) - form_result["active"] = auto_active - external_identity = self.request.POST.get("external_identity") + form_result = otp_form.to_python(self.request.POST) + code = form_result["otp"] - if external_identity: - extern_name = external_identity - extern_type = external_identity + if not user.verify_otp(code, "sudo"): + Session().commit() + error_msg = _("Invalid or expired code. Please try again.") + raise formencode.Invalid(error_msg, form_result, None, + error_dict={"otp": error_msg}) - if captcha.active: - captcha_status, captcha_message = self.validate_captcha(captcha.private_key) - - if not captcha_status: - _value = form_result - _msg = _("Bad captcha") - error_dict = {"recaptcha_field": captcha_message} - raise formencode.Invalid(_msg, _value, None, error_dict=error_dict) - - new_user = UserModel().create_registration(form_result, extern_name=extern_name, extern_type=extern_type) - - action_data = {"data": new_user.get_api_data(), "user_agent": self.request.user_agent} - - if external_identity: - action_data["external_identity"] = external_identity - - audit_user = audit_logger.UserWrap( - username=new_user.username, user_id=new_user.user_id, ip_addr=self.request.remote_addr - ) - - audit_logger.store_web("user.register", action_data=action_data, user=audit_user) - - event = UserRegistered(user=new_user, session=self.session) - trigger(event) - h.flash(_("You have successfully registered with RhodeCode. You can log-in now."), category="success") - if external_identity: - h.flash( - _("Please use the {identity} button to log-in").format(identity=external_identity), - category="success", - ) Session().commit() - redirect_ro = self.request.route_path("login") - raise HTTPFound(redirect_ro) + self.session["sudo_otp_verified_at"] = time.time() + next_url = self.session.pop("sudo_next_url", h.route_path("home")) + if hasattr(self.session, 'save'): + self.session.save() + + raise HTTPFound(next_url) except formencode.Invalid as errors: - errors.value.pop("password", None) - errors.value.pop("password_confirmation", None) - return self.register(defaults=errors.value, errors=errors.error_dict) - - except UserCreationError as e: - # container auth or other auth functions that create users on - # the fly can throw this exception signaling that there's issue - # with user creation, explanation should be provided in - # Exception itself - h.flash(e, category="error") - return self.register() - - def password_reset(self): - c = self.load_default_context() - captcha = self._get_captcha_data() - - template_context = { - "captcha_active": captcha.active, - "captcha_public_key": captcha.public_key, - "defaults": {}, - "errors": {}, - } - - # always send implicit message to prevent from discovery of - # matching emails - msg = _("If such email exists, a password reset link was sent to it.") - - def default_response(): - log.debug("faking response on invalid password reset") - # make this take 2s, to prevent brute forcing. - time.sleep(2) - h.flash(msg, category="success") - return HTTPFound(self.request.route_path("reset_password")) - - if self.request.POST: - if h.HasPermissionAny("hg.password_reset.disabled")(): - _email = self.request.POST.get("email", "") - log.error("Failed attempt to reset password for `%s`.", _email) - h.flash(_("Password reset has been disabled."), category="error") - return HTTPFound(self.request.route_path("reset_password")) - - password_reset_form = PasswordResetForm(self.request.translate)() - description = "Generated token for password reset from {}".format(datetime.datetime.now().isoformat()) - - try: - form_result = password_reset_form.to_python(self.request.POST) - user_email = form_result["email"] - - if captcha.active: - captcha_status, captcha_message = self.validate_captcha(captcha.private_key) - - if not captcha_status: - _value = form_result - _msg = _("Bad captcha") - error_dict = {"recaptcha_field": captcha_message} - raise formencode.Invalid(_msg, _value, None, error_dict=error_dict) - - # Generate reset URL and send mail. - user = User.get_by_email(user_email) - - # only allow rhodecode based users to reset their password - # external auth shouldn't allow password reset - if user and user.extern_type != auth_rhodecode.RhodeCodeAuthPlugin.uid: - log.warning( - "User %s with external type `%s` tried a password reset. This try was rejected", - user, - user.extern_type, - ) - return default_response() - - # generate password reset token that expires in 10 minutes - reset_token = UserModel().add_auth_token( - user=user, - lifetime_minutes=10, - role=UserModel.auth_token_role.ROLE_PASSWORD_RESET, - description=description, - ) - Session().commit() - - log.debug("Successfully created password recovery token") - password_reset_url = self.request.route_url( - "reset_password_confirmation", _query={"key": reset_token.api_key} - ) - UserModel().reset_password_link(form_result, password_reset_url) - - action_data = {"email": user_email, "user_agent": self.request.user_agent} - audit_logger.store_web( - "user.password.reset_request", action_data=action_data, user=self._rhodecode_user, commit=True - ) - - return default_response() - - except formencode.Invalid as errors: - template_context.update( - { - "defaults": errors.value, - "errors": errors.error_dict, - } - ) - if not self.request.POST.get("email"): - # case of empty email, we want to report that - return self._get_template_context(c, **template_context) - - if "recaptcha_field" in errors.error_dict: - # case of failed captcha - return self._get_template_context(c, **template_context) - - return default_response() - - return self._get_template_context(c, **template_context) - - def password_reset_confirmation(self): - self.load_default_context() - - if key := self.request.GET.get("key"): - # make this take 2s, to prevent brute forcing. - time.sleep(2) - - token = AuthTokenModel().get_auth_token(key) - - # verify token is the correct role - if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET: - log.debug( - "Got token with role:%s expected is %s", - getattr(token, "role", "EMPTY_TOKEN"), - UserApiKeys.ROLE_PASSWORD_RESET, - ) - h.flash(_("Given reset token is invalid"), category="error") - return HTTPFound(self.request.route_path("reset_password")) - - try: - owner = token.user - data = {"email": owner.email, "token": token.api_key} - UserModel().reset_password(data) - h.flash( - _("Your password reset was successful, a new password has been sent to your email"), - category="success", - ) - except Exception as e: - log.error(e) - return HTTPFound(self.request.route_path("reset_password")) - - return HTTPFound(self.request.route_path("login")) - - @LoginRequired() - @NotAnonymous() - def setup_2fa(self): - _ = self.request.translate - c = self.load_default_context() - user_instance = self._rhodecode_db_user - form = TOTPForm(_, user_instance)() - render_ctx = {} - if self.request.method == "POST": - post_items = dict(self.request.POST) - - try: - form_details = form.to_python(post_items) - secret = form_details["secret_totp"] - - user_instance.init_2fa_recovery_codes(persist=True, force=True) - user_instance.secret_2fa = secret - - Session().commit() - raise HTTPFound(self.request.route_path("my_account_configure_2fa", _query={"show-recovery-codes": 1})) - except formencode.Invalid as errors: - defaults = errors.value - render_ctx = { - "errors": errors.error_dict, - "defaults": defaults, - } - - # NOTE: here we DO NOT persist the secret 2FA, since this is only for setup, once a setup is completed - # only then we should persist it - secret = user_instance.init_secret_2fa(persist=False) - - instance_name = rhodecode.ConfigGet().get_str("app.base_url", "rhodecode") - totp_name = f"{instance_name}:{self.request.user.username}" - - qr = qrcode.QRCode(version=1, box_size=5, border=4) - qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(name=totp_name)) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - buffered = BytesIO() - img.save(buffered) - return self._get_template_context( - c, qr=b64encode(buffered.getvalue()).decode("utf-8"), key=secret, totp_name=totp_name, **render_ctx - ) - - @LoginRequired() - @NotAnonymous() - def verify_2fa(self): - _ = self.request.translate - c = self.load_default_context() - render_ctx = {} - user_instance = self._rhodecode_db_user - totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)() - if self.request.method == "POST": - post_items = dict(self.request.POST) - # NOTE: inject secret, as it's a post configured saved item. - post_items["secret_totp"] = user_instance.secret_2fa - try: - totp_form.to_python(post_items) - user_instance.check_2fa_required = False - Session().commit() - raise HTTPFound(c.came_from) - except formencode.Invalid as errors: - defaults = errors.value - render_ctx = { - "errors": errors.error_dict, - "defaults": defaults, - } - return self._get_template_context(c, **render_ctx) + defaults = errors.value + render_ctx = { + "errors": errors.error_dict, + "defaults": defaults, + } + return self._get_template_context(c, **render_ctx) diff --git a/rhodecode/apps/my_account/views/my_account.py b/rhodecode/apps/my_account/views/my_account.py index 9fb4311e..b06b4bb9 100644 --- a/rhodecode/apps/my_account/views/my_account.py +++ b/rhodecode/apps/my_account/views/my_account.py @@ -58,7 +58,6 @@ from rhodecode.model.db import ( RepoGroup, ChangesetStatus, ) -from rhodecode.model.forms import TOTPForm from rhodecode.model.meta import Session from rhodecode.model.pull_request import PullRequestModel from rhodecode.model.user import UserModel @@ -257,26 +256,8 @@ class MyAccountView(BaseAppView, DataGridAppView): @CSRFRequired() def my_account_2fa_regenerate_recovery_codes(self): _ = self.request.translate - c = self.load_default_context() - user_instance = c.auth_user.get_instance() - - totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)() - - post_items = dict(self.request.POST) - # NOTE: inject secret, as it's a post configured saved item. - post_items["secret_totp"] = user_instance.secret_2fa - try: - totp_form.to_python(post_items) - user_instance.regenerate_2fa_recovery_codes() - Session().commit() - except formencode.Invalid as errors: - h.flash(_("Failed to generate new recovery codes: {}").format(errors), category="error") - raise HTTPFound(self.request.route_path("my_account_configure_2fa")) - except Exception as e: - h.flash(_("Failed to generate new recovery codes: {}").format(e), category="error") - raise HTTPFound(self.request.route_path("my_account_configure_2fa")) - - raise HTTPFound(self.request.route_path("my_account_configure_2fa", _query={"show-recovery-codes": 1})) + h.flash(_("2FA recovery codes are no longer used."), category="warning") + raise HTTPFound(self.request.route_path("my_account_configure_2fa")) @LoginRequired() @NotAnonymous() diff --git a/rhodecode/apps/repository/views/repo_pull_requests.py b/rhodecode/apps/repository/views/repo_pull_requests.py index 1d966843..4f701a32 100644 --- a/rhodecode/apps/repository/views/repo_pull_requests.py +++ b/rhodecode/apps/repository/views/repo_pull_requests.py @@ -42,6 +42,7 @@ from rhodecode.lib.auth import ( NotAnonymous, CSRFRequired, ) +from rhodecode.lib.auth_otp import SudoOTPRequired from rhodecode.lib.utils2 import str2bool, safe_str, safe_int, aslist, retry from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason, unicode_to_reference from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError @@ -1678,6 +1679,7 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView): @NotAnonymous() @HasRepoPermissionAnyDecorator("repository.read", "repository.write", "repository.admin") @CSRFRequired() + @SudoOTPRequired def pull_request_merge(self): """ Merge will perform a server-side merge of the specified diff --git a/rhodecode/apps/repository/views/repo_settings_advanced.py b/rhodecode/apps/repository/views/repo_settings_advanced.py index 6b81ca3a..9fc227c3 100644 --- a/rhodecode/apps/repository/views/repo_settings_advanced.py +++ b/rhodecode/apps/repository/views/repo_settings_advanced.py @@ -26,6 +26,7 @@ from rhodecode.apps._base import RepoAppView from rhodecode.lib import helpers as h from rhodecode.lib import audit_logger from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired, HasRepoPermissionAny +from rhodecode.lib.auth_otp import SudoOTPRequired from rhodecode.lib.exceptions import AttachedForksError, AttachedPullRequestsError, AttachedArtifactsError from rhodecode.lib.type_utils import str2bool from rhodecode.lib.utils2 import safe_int @@ -142,6 +143,7 @@ class RepoSettingsAdvancedView(RepoAppView): @LoginRequired() @HasRepoPermissionAnyDecorator("repository.admin") @CSRFRequired() + @SudoOTPRequired def edit_advanced_delete(self): """ Deletes the repository, or shows warnings if deletion is not possible diff --git a/rhodecode/apps/ssh_support/lib/ssh_wrapper_v1.py b/rhodecode/apps/ssh_support/lib/ssh_wrapper_v1.py index bd8bc4ba..686447bc 100644 --- a/rhodecode/apps/ssh_support/lib/ssh_wrapper_v1.py +++ b/rhodecode/apps/ssh_support/lib/ssh_wrapper_v1.py @@ -30,7 +30,7 @@ import click from rhodecode.lib.pyramid_utils import bootstrap from rhodecode.lib.statsd_client import StatsdClient -from rhodecode.config.patches import suppress_pkg_resources_warnings + from .backends import SshWrapper from .utils import setup_custom_logging @@ -53,9 +53,6 @@ log = logging.getLogger(__name__) @click.option("--shell", "-s", is_flag=True, help="Allow Shell") @click.option("--debug", is_flag=True, help="Enabled detailed output logging") def main(ini_path, mode, user, user_id, key_id, shell, debug): - # Apply pkg_resources deprecation warning suppression early - suppress_pkg_resources_warnings() - setup_custom_logging(ini_path, debug) command = os.environ.get("SSH_ORIGINAL_COMMAND", "") diff --git a/rhodecode/apps/ssh_support/lib/ssh_wrapper_v2.py b/rhodecode/apps/ssh_support/lib/ssh_wrapper_v2.py index 8cf95e65..ab3ff571 100644 --- a/rhodecode/apps/ssh_support/lib/ssh_wrapper_v2.py +++ b/rhodecode/apps/ssh_support/lib/ssh_wrapper_v2.py @@ -37,7 +37,7 @@ import logging import click from rhodecode.config.config_maker import sanitize_settings_and_apply_defaults -from rhodecode.config.patches import suppress_pkg_resources_warnings + from rhodecode.lib.request import Request from rhodecode.lib.utils2 import AttributeDict from rhodecode.lib.statsd_client import StatsdClient @@ -65,9 +65,6 @@ log = logging.getLogger(__name__) @click.option("--shell", "-s", is_flag=True, help="Allow Shell") @click.option("--debug", is_flag=True, help="Enabled detailed output logging") def main(ini_path, mode, user, user_id, key_id, shell, debug): - # Apply pkg_resources deprecation warning suppression early - suppress_pkg_resources_warnings() - time_start = time.time() setup_custom_logging(ini_path, debug) diff --git a/rhodecode/config/middleware.py b/rhodecode/config/middleware.py index d78947fb..d614e78b 100644 --- a/rhodecode/config/middleware.py +++ b/rhodecode/config/middleware.py @@ -106,7 +106,7 @@ def make_pyramid_app(global_config, **settings): # Apply compatibility patches patches.inspect_getargspec() patches.repoze_sendmail_lf_fix() - patches.suppress_pkg_resources_warnings() + # first init, so load_pyramid_enviroment, can access some critical data, like __file__ propagate_rhodecode_config(global_config, {}, {}, full=False) diff --git a/rhodecode/config/patches.py b/rhodecode/config/patches.py index e732bad2..4aecff7b 100644 --- a/rhodecode/config/patches.py +++ b/rhodecode/config/patches.py @@ -171,15 +171,3 @@ def repoze_sendmail_lf_fix(): encoding.encode_message = lambda message, *args, **kwargs: message.as_bytes(policy=SMTP) -def suppress_pkg_resources_warnings(): - """ - Suppress pkg_resources deprecation warnings from any module. - - pkg_resources is deprecated as of setuptools 81+ and will be removed in 2025-11. - This warning is generated by various modules including pyramid's asset resolution code - which we cannot easily modify. The warning is informational only and does not affect functionality. - """ - import warnings - - warnings.filterwarnings("ignore", message="pkg_resources is deprecated as an API.*", category=UserWarning) - warnings.filterwarnings("ignore", message="pkg_resources is deprecated as an API.*", category=DeprecationWarning) diff --git a/rhodecode/lib/auth_otp.py b/rhodecode/lib/auth_otp.py new file mode 100644 index 00000000..4f18e898 --- /dev/null +++ b/rhodecode/lib/auth_otp.py @@ -0,0 +1,58 @@ +import time +import logging +import functools + +from pyramid.httpexceptions import HTTPFound + +from rhodecode.lib import helpers as h +from rhodecode.model.meta import Session + +log = logging.getLogger(__name__) + +SUDO_GRACE_PERIOD = 600 # 10 minutes + + +class SudoOTPRequired(object): + def __init__(self, func): + functools.update_wrapper(self, func) + self.func = func + + def __get__(self, obj, cls): + return functools.partial(self.__call__, obj) + + def __call__(self, method_self, *args, **kwargs): + session = method_self.request.session + verified_at = session.get("sudo_otp_verified_at", 0) + + if (time.time() - verified_at) < SUDO_GRACE_PERIOD: + return self.func(method_self, *args, **kwargs) + + # Need sudo verification — generate OTP and redirect + user = method_self._rhodecode_db_user + code = user.generate_otp("sudo", digits=8) + Session().commit() + + import rhodecode + smtp_server = rhodecode.CONFIG.get('smtp_server') + log_code = code if not smtp_server else 'X' * len(code) + log.info('[OTP] code=%s email=%s purpose=sudo', log_code, user.email) + + # Send OTP email + from rhodecode.lib.celerylib import tasks, run_task + from rhodecode.model.notification import EmailNotificationModel + + email_kwargs = { + 'otp_code': code, + 'purpose': 'sudo', + } + (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email( + EmailNotificationModel.TYPE_OTP_CODE, **email_kwargs + ) + run_task(tasks.send_email, [user.email], subject, email_body_plaintext, email_body) + + # Store where to go after verification + session["sudo_next_url"] = method_self.request.url + if hasattr(session, 'save'): + session.save() + + raise HTTPFound(method_self.request.route_path("sudo_otp_verify")) diff --git a/rhodecode/lib/middleware/vcs.py b/rhodecode/lib/middleware/vcs.py index ff166c90..29d211c0 100644 --- a/rhodecode/lib/middleware/vcs.py +++ b/rhodecode/lib/middleware/vcs.py @@ -155,10 +155,13 @@ def detect_vcs_request(environ, backends): "_file_store++", # login f"{ADMIN_PREFIX}/login", + f"{ADMIN_PREFIX}/login/verify", f"{ADMIN_PREFIX}/logout", - # 2fa - f"{ADMIN_PREFIX}/check_2fa", - f"{ADMIN_PREFIX}/setup_2fa", + # otp + f"{ADMIN_PREFIX}/otp/resend", + f"{ADMIN_PREFIX}/sudo/verify", + # register redirects to login + f"{ADMIN_PREFIX}/register", # _admin/api is safe too f"{ADMIN_PREFIX}/api", # _admin/gist is safe too diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py index 3f2f1ad2..2e6754d5 100644 --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -1017,6 +1017,53 @@ class User(Base, BaseModel): Session().commit() return new_recovery_codes + def generate_otp(self, purpose, digits=6): + import secrets + import hashlib + code = ''.join(str(secrets.randbelow(10)) for _ in range(digits)) + code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest() + self.update_userdata( + otp_code=code_hash, + otp_created_at=time.time(), + otp_attempts=0, + otp_purpose=purpose, + ) + return code + + def verify_otp(self, code, purpose, max_age=300, max_attempts=5): + import hashlib + stored_hash = self.user_data.get('otp_code') + created_at = self.user_data.get('otp_created_at', 0) + attempts = self.user_data.get('otp_attempts', 0) + stored_purpose = self.user_data.get('otp_purpose') + + if not stored_hash or stored_purpose != purpose: + return False + + if attempts >= max_attempts: + self.clear_otp() + return False + + if (time.time() - created_at) > max_age: + self.clear_otp() + return False + + self.update_userdata(otp_attempts=attempts + 1) + + code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest() + if code_hash == stored_hash: + self.clear_otp() + return True + return False + + def clear_otp(self): + self.update_userdata( + otp_code=None, + otp_created_at=None, + otp_attempts=None, + otp_purpose=None, + ) + @classmethod def extra_valid_auth_tokens(cls, user, role=None): tokens = ( diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py index 31cb9681..ece9549b 100644 --- a/rhodecode/model/forms.py +++ b/rhodecode/model/forms.py @@ -86,54 +86,22 @@ def LoginForm(localizer): class _LoginForm(formencode.Schema): allow_extra_fields = True filter_extra_fields = True - username = v.UnicodeString( - strip=True, - min=1, - not_empty=True, - messages={ - "empty": _("Please enter a login"), - "tooShort": _("Enter a value %(min)i characters long or more"), - }, - ) - - password = v.UnicodeString( - strip=False, - min=3, - max=72, - not_empty=True, - messages={"empty": _("Please enter a password"), "tooShort": _("Enter %(min)i characters or more")}, - ) - + email = All(v.Email(not_empty=True), v.UnicodeString(strip=True, min=1, not_empty=True)) remember = v.StringBoolean(if_missing=False) - chained_validators = [v.ValidAuth(localizer)] - return _LoginForm -def TOTPForm(localizer, user, allow_recovery_code_use=False): +def OTPVerifyForm(localizer, digits=6): _ = localizer - class _TOTPForm(formencode.Schema): + class _OTPVerifyForm(formencode.Schema): allow_extra_fields = True - filter_extra_fields = False - totp = v.Regex(r"^(?:\d{6}|[A-Z0-9]{32})$") - secret_totp = v.String() + filter_extra_fields = True + otp = v.Regex(r'^\d{%d}$' % digits, not_empty=True, + messages={'invalid': _('Please enter a valid %d-digit code') % digits}) - def to_python(self, value, state=None): - validation_checks = [user.is_totp_valid] - if allow_recovery_code_use: - validation_checks.append(user.is_2fa_recovery_code_valid) - form_data = super().to_python(value, state) - received_code = form_data["totp"] - secret = form_data.get("secret_totp") - - if not any(map(lambda func: func(received_code, secret), validation_checks)): - error_msg = _("Code is invalid. Try again!") - raise formencode.Invalid(error_msg, v, state, error_dict={"totp": error_msg}) - return form_data - - return _TOTPForm + return _OTPVerifyForm def WhitelistedVcsClientsForm(localizer): @@ -246,32 +214,11 @@ def RegisterForm(localizer, edit=False, old_data=None): class _RegisterForm(formencode.Schema): allow_extra_fields = True filter_extra_fields = True - username = All(v.ValidUsername(localizer, edit, old_data), v.UnicodeString(strip=True, min=1, not_empty=True)) - password = All(v.ValidPassword(localizer), v.UnicodeString(strip=False, min=6, max=72, not_empty=True)) - password_confirmation = All( - v.ValidPassword(localizer), v.UnicodeString(strip=False, min=6, max=72, not_empty=True) - ) - active = v.StringBoolean(if_missing=False) - firstname = v.UnicodeString(strip=True, min=1, not_empty=False) - lastname = v.UnicodeString(strip=True, min=1, not_empty=False) email = All(v.UniqSystemEmail(localizer, old_data), v.Email(not_empty=True)) - chained_validators = [v.ValidPasswordsMatch(localizer)] - return _RegisterForm -def PasswordResetForm(localizer): - _ = localizer - - class _PasswordResetForm(formencode.Schema): - allow_extra_fields = True - filter_extra_fields = True - email = All(v.ValidSystemEmail(localizer), v.Email(not_empty=True)) - - return _PasswordResetForm - - def RepoForm(localizer, edit=False, old_data=None, repo_groups=None, allow_disabled=False): _ = localizer old_data = old_data or {} diff --git a/rhodecode/model/notification.py b/rhodecode/model/notification.py index 4647ffad..c886d88b 100644 --- a/rhodecode/model/notification.py +++ b/rhodecode/model/notification.py @@ -378,13 +378,12 @@ class EmailNotificationModel(BaseModel): TYPE_PULL_REQUEST_UPDATE = Notification.TYPE_PULL_REQUEST_UPDATE TYPE_MAIN = Notification.TYPE_MESSAGE - TYPE_PASSWORD_RESET = "password_reset" - TYPE_PASSWORD_RESET_CONFIRMATION = "password_reset_confirmation" TYPE_EMAIL_TEST = "email_test" TYPE_EMAIL_EXCEPTION = "exception" TYPE_UPDATE_AVAILABLE = "update_available" TYPE_TEST = "test" TYPE_SECURITY_AUDIT = "security_audit" + TYPE_OTP_CODE = "otp_code" email_types = { TYPE_MAIN: "rhodecode:templates/email_templates/main.mako", @@ -393,13 +392,12 @@ class EmailNotificationModel(BaseModel): TYPE_UPDATE_AVAILABLE: "rhodecode:templates/email_templates/update_available.mako", TYPE_EMAIL_TEST: "rhodecode:templates/email_templates/email_test.mako", TYPE_REGISTRATION: "rhodecode:templates/email_templates/user_registration.mako", - TYPE_PASSWORD_RESET: "rhodecode:templates/email_templates/password_reset.mako", - TYPE_PASSWORD_RESET_CONFIRMATION: "rhodecode:templates/email_templates/password_reset_confirmation.mako", TYPE_COMMIT_COMMENT: "rhodecode:templates/email_templates/commit_comment.mako", TYPE_PULL_REQUEST: "rhodecode:templates/email_templates/pull_request_review.mako", TYPE_PULL_REQUEST_COMMENT: "rhodecode:templates/email_templates/pull_request_comment.mako", TYPE_PULL_REQUEST_UPDATE: "rhodecode:templates/email_templates/pull_request_update.mako", TYPE_SECURITY_AUDIT: "rhodecode:templates/email_templates/security_audit.mako", + TYPE_OTP_CODE: "rhodecode:templates/email_templates/otp_code.mako", } premailer_instance = premailer.Premailer( diff --git a/rhodecode/model/validators.py b/rhodecode/model/validators.py index fe029f81..d1b9b991 100644 --- a/rhodecode/model/validators.py +++ b/rhodecode/model/validators.py @@ -423,37 +423,6 @@ def ValidPasswordsMatch(localizer, passwd="new_password", passwd_confirmation="p return _validator -def ValidAuth(localizer): - _ = localizer - - class _validator(formencode.validators.FancyValidator): - messages = { - "invalid_password": _("invalid password"), - "invalid_username": _("invalid user name"), - "disabled_account": _("Your account is disabled"), - } - - def _validate_python(self, value, state): - from rhodecode.authentication.base import authenticate, HTTP_TYPE - - password = value["password"] - username = value["username"] - - if not authenticate(username, password, "", HTTP_TYPE, skip_missing=True): - user = User.get_by_username_or_primary_email(username) - if user and not user.active: - log.warning("user %s is disabled", username) - msg = M(self, "disabled_account", state) - raise formencode.Invalid(msg, value, state, error_dict={"username": msg}) - else: - log.warning("user `%s` failed to authenticate", username) - msg = M(self, "invalid_username", state) - msg2 = M(self, "invalid_password", state) - raise formencode.Invalid(msg, value, state, error_dict={"username": msg, "password": msg2}) - - return _validator - - def ValidRepoName(localizer, edit=False, old_data=None): old_data = old_data or {} _ = localizer diff --git a/rhodecode/public/css/styleguide.css b/rhodecode/public/css/styleguide.css index 42c06bb0..c0345a39 100644 --- a/rhodecode/public/css/styleguide.css +++ b/rhodecode/public/css/styleguide.css @@ -33,8 +33,8 @@ /* Typography */ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; --font-mono: 'Berkeley Mono', 'JetBrains Mono', 'Fira Code', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace; - --font-size: 16px; - --line-height: 1.6; + --font-size: 15px; + --line-height: 1.5; /* Spacing scale (4px base) */ --space-1: 4px; @@ -60,16 +60,18 @@ /* Layout */ --content-max: 1200px; --sidebar-width: 260px; + + /* Header */ + --header-bg: #1a1a2e; + --header-text: #ffffff; } /* ======================================================================== - RESET & BASE + RESET & BASE — applies to all pages ======================================================================== */ *, *::before, *::after { box-sizing: border-box; - margin: 0; - padding: 0; } html { @@ -79,16 +81,1905 @@ html { -moz-osx-font-smoothing: grayscale; } -body.sg-body { +body { + margin: 0; + padding: 0; font-family: var(--font-sans); + font-size: var(--font-size); color: var(--text); background: var(--bg); } +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; + color: var(--accent-hover); +} + +img { + max-width: 100%; + height: auto; +} + +h1, h2, h3, h4, h5, h6 { + line-height: 1.3; + font-weight: 600; +} + /* ======================================================================== - LAYOUT — CSS Grid Only, No Flexbox + APP LAYOUT — targets actual RhodeCode template markup ======================================================================== */ +/* --- Main page structure --- */ +.outerwrapper { + min-height: 100vh; +} + +.wrapper { + max-width: var(--content-max); + margin: 0 auto; + padding: 0 var(--space-4); +} + +/* --- Header --- */ +.header { + background: var(--header-bg); + border-bottom: var(--border-width) solid rgba(255, 255, 255, 0.1); + position: sticky; + top: 0; + z-index: 100; +} + +.header #header-inner { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--space-4); + padding: var(--space-2) var(--space-4); + max-width: var(--content-max); + margin: 0 auto; +} + +#logo { + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: var(--space-3); +} + +#logo .logo-wrapper a { + display: block; + line-height: 0; +} + +#logo .logo-wrapper img { + height: 32px; + width: auto; +} + +#logo .branding a { + color: var(--header-text); + font-weight: 600; + font-size: 1rem; + text-decoration: none; +} + +/* --- Navigation --- */ +ul.main_nav, +ul.navigation, +ul.horizontal-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + gap: var(--space-1); + align-items: center; +} + +.header .menulink, +.header .menulink a { + color: rgba(255, 255, 255, 0.7); + text-decoration: none; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + transition: color 0.15s, background 0.15s; + display: block; +} + +.header .menulink:hover, +.header .menulink a:hover { + color: var(--header-text); + background: rgba(255, 255, 255, 0.15); + text-decoration: none; +} + +/* Active nav item */ +.header li.active .menulink, +.header li.active .menulink a { + color: var(--header-text); + background: rgba(255, 255, 255, 0.12); +} + +.menulabel { + font-size: 0.875rem; +} + +/* Suppress native title tooltips on nav elements */ +.header [title] { + pointer-events: auto; +} +.header .menulink[title]::after { + display: none; +} + +/* --- Search / goto bar --- */ +.main_filter_box { + justify-self: center; +} + +.main_filter_input_box { + position: relative; +} + +#main_filter { + padding: var(--space-2) var(--space-3); + padding-left: var(--space-5); + font-family: var(--font-sans); + font-size: 0.875rem; + color: var(--text); + background: rgba(255, 255, 255, 0.9); + border: var(--border-width) solid rgba(255, 255, 255, 0.3); + border-radius: var(--radius-md); + width: 320px; + transition: width 0.2s, background 0.15s; +} + +#main_filter:focus { + width: 400px; + background: var(--surface); + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px var(--accent-light); +} + +#main_filter::placeholder { + color: var(--text-muted); +} + +/* --- Context bar (repo, admin subnav) --- */ +#context-bar { + background: var(--surface); + border-bottom: var(--border-width) solid var(--border); + padding: 0 var(--space-4); +} + +#context-bar .wrapper { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: var(--space-4); +} + +#context-bar .title { + padding: var(--space-3) 0; +} + +#context-bar .title-content { + font-weight: 600; + font-size: 0.9375rem; +} + +#context-bar ul.navigation { + justify-self: start; +} + +#context-bar .navigation li { + list-style: none; +} + +#context-bar .navigation a { + display: block; + padding: var(--space-2) var(--space-3); + color: var(--text-muted); + text-decoration: none; + font-size: 0.8125rem; + font-weight: 500; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; +} + +#context-bar .navigation a:hover { + color: var(--text); + border-bottom-color: var(--border); + text-decoration: none; +} + +#context-bar .navigation a.current, +#context-bar .navigation li.active a { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* --- Main content area --- */ +#content { + padding: var(--space-5) var(--space-4); +} + +#content .main { + max-width: var(--content-max); + margin: 0 auto; +} + +/* ======================================================================== + PAGE LAYOUT — left sidebar + content + ======================================================================== */ + +.page-with-sidebar { + display: grid; + grid-template-columns: 200px 1fr; + gap: var(--space-5); + align-items: start; +} + +/* Left sidebar nav */ +.page-sidebar { + position: sticky; + top: var(--space-4); + padding-right: var(--space-3); + border-right: var(--border-width) solid var(--border); +} + +.sidebar-section { + margin-bottom: var(--space-4); +} + +.sidebar-heading { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + padding: 0 var(--space-2); + margin: 0 0 var(--space-2) 0; +} + +.sidebar-nav { + list-style: none; + margin: 0; + padding: 0; +} + +.sidebar-nav li a { + display: grid; + grid-template-columns: 18px 1fr; + gap: var(--space-2); + align-items: center; + padding: var(--space-1) var(--space-2); + color: var(--text-muted); + text-decoration: none; + font-size: 0.8125rem; + border-radius: var(--radius-sm); + transition: color 0.15s, background 0.15s; +} + +.sidebar-nav li a:hover { + color: var(--text); + background: var(--surface-alt); + text-decoration: none; +} + +.sidebar-nav li a i { + font-size: 0.75rem; + text-align: center; + color: var(--text-muted); +} + +.sidebar-nav li a:hover i { + color: var(--accent); +} + +.sidebar-nav li.active a { + color: var(--accent); + background: var(--accent-light); + font-weight: 600; +} + +.sidebar-nav li.active a i { + color: var(--accent); +} + +/* Hide old horizontal admin sub-nav if rendered */ +#context-bar { display: none; } + +.page-content { + min-width: 0; +} + +@media (max-width: 768px) { + .page-with-sidebar { + grid-template-columns: 1fr; + } + + .page-sidebar { + position: static; + border-right: none; + border-bottom: var(--border-width) solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + } + + .sidebar-nav { + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + gap: var(--space-1); + overflow-x: auto; + } +} + +/* --- Footer — old ID-based styles replaced by .site-footer in FOOTER section --- */ + +/* --- Hide keyboard shortcuts modal (binding disabled, CSS safety net) --- */ +#help_kb { display: none !important; } + +/* --- Admin dashboard --- */ +.admin-dashboard h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 var(--space-4); + color: var(--text); +} + +.admin-dashboard h3 { + font-size: 0.9375rem; + font-weight: 600; + margin: 0 0 var(--space-3); + color: var(--text); +} + +.dashboard-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-5); +} + +.dashboard-card { + display: grid; + gap: var(--space-1); + padding: var(--space-4); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + text-decoration: none; + text-align: center; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.dashboard-card:hover { + border-color: var(--accent); + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + text-decoration: none; +} + +.dashboard-card-count { + font-size: 2rem; + font-weight: 700; + color: var(--accent); + line-height: 1; +} + +.dashboard-card-label { + font-size: 0.8125rem; + color: var(--text-muted); + font-weight: 500; +} + +.dashboard-actions { + padding-top: var(--space-4); + border-top: 1px solid var(--border); +} + +.dashboard-action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--space-2); +} + +/* --- User menu --- */ +#quick_login_link { + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: var(--space-2); + color: var(--header-text); + text-decoration: none; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + cursor: pointer; +} + +#quick_login_link:hover { + background: rgba(255, 255, 255, 0.1); + text-decoration: none; +} + +#quick_login_link .menu_link_user { + font-size: 0.875rem; + font-weight: 500; +} + +#quick_login_link .show_more { + display: inline-block; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid currentColor; + margin-left: 2px; + vertical-align: middle; +} + +.gravatar { + border-radius: 50%; + vertical-align: middle; +} + +/* --- Dropdowns / submenus (shared) --- */ +.submenu { + position: absolute; + top: 100%; + right: 0; + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 200; + min-width: 200px; + padding: var(--space-2) 0; +} + +.submenu ol, +.submenu ul { + list-style: none; + margin: 0; + padding: 0; +} + +.submenu li { + list-style: none; +} + +.submenu li a { + display: block; + padding: var(--space-2) var(--space-4); + color: var(--text); + font-size: 0.8125rem; + text-decoration: none; + transition: background 0.1s; +} + +.submenu li a:hover { + background: var(--surface-alt); + color: var(--accent); + text-decoration: none; +} + +/* --- Submenu section titles --- */ +.submenu .submenu-title, +.submenu li.submenu-title { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + padding: var(--space-3) var(--space-4) var(--space-1); +} + +/* ======================================================================== + LOGIN PAGE — standalone layout (inherits root.mako, not base.mako) + ======================================================================== */ + +.loginbox { + min-height: 100vh; + display: grid; + grid-template-rows: auto auto 1fr; + background: var(--surface-alt); +} + +.header-account { + background: var(--header-bg); + padding: var(--space-4) var(--space-5); +} + +.header-account .title { + max-width: var(--content-max); + margin: 0 auto; +} + +.header-account #logo { + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: var(--space-3); +} + +.header-account #logo img { + height: 40px; + width: auto; +} + +.header-account .branding a { + color: var(--header-text); + font-weight: 600; + font-size: 1.125rem; + text-decoration: none; +} + +.loginwrapper { + display: grid; + grid-template-columns: 1fr minmax(340px, 420px); + align-items: center; + gap: var(--space-7); + max-width: 900px; + margin: 0 auto; + padding: var(--space-7) var(--space-5); +} + +/* Single-column loginwrapper when no auth-image */ +.loginwrapper:not(:has(.auth-image-wrapper)) { + grid-template-columns: minmax(340px, 520px); + justify-content: center; +} + +.auth-image-wrapper { + justify-self: center; +} + +.sign-in-image { + max-width: 300px; + height: auto; + opacity: 0.9; +} + +#login, +#register { + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: var(--space-6); +} + +.sign-in-title h1 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: var(--space-5); + color: var(--text); +} + +#login .inner.form label, +#register .inner.form label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text); + margin-bottom: var(--space-1); + margin-top: var(--space-4); +} + +#login .inner.form label:first-of-type, +#register .inner.form label:first-of-type { + margin-top: 0; +} + +#login .inner.form input[type="text"], +#login .inner.form input[type="password"], +#register .inner.form input[type="text"], +#register .inner.form input[type="password"] { + display: block; + width: 100%; + padding: var(--space-2) var(--space-3); + font-family: var(--font-sans); + font-size: 0.9375rem; + color: var(--text); + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + transition: border-color 0.15s, box-shadow 0.15s; +} + +#login .inner.form input[type="text"]:focus, +#login .inner.form input[type="password"]:focus, +#register .inner.form input[type="text"]:focus, +#register .inner.form input[type="password"]:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +#login .inner.form input[type="checkbox"], +#register .inner.form input[type="checkbox"] { + margin-right: var(--space-2); + vertical-align: middle; +} + +#login .inner.form label.checkbox, +#register .inner.form label.checkbox { + display: inline; + font-weight: 400; + color: var(--text-muted); + font-size: 0.875rem; + margin-top: var(--space-3); +} + +#login .inner.form .pull-right, +#register .inner.form .pull-right { + float: right; + font-size: 0.8125rem; +} + +#login .inner.form .pwd_reset { + color: var(--accent); + font-size: 0.8125rem; +} + +#login .inner.form .links, +#register .inner.form .links { + margin-top: var(--space-4); + font-size: 0.875rem; +} + +#login .inner.form .links a, +#register .inner.form .links a { + color: var(--accent); +} + +#login .inner.form .help-block, +#register .inner.form .help-block { + margin-top: var(--space-3); + font-size: 0.8125rem; + color: var(--text-muted); + line-height: 1.5; +} + +#login .error-message, +#register .error-message { + color: var(--danger); + font-size: 0.8125rem; + margin-top: var(--space-1); +} + +/* --- Sign in button --- */ +.btn.sign-in { + display: block; + width: 100%; + padding: var(--space-3); + margin-top: var(--space-5); + font-family: var(--font-sans); + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-inverse); + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s; +} + +.btn.sign-in:hover { + background: var(--accent-hover); +} + +/* ======================================================================== + NOTIFICATIONS / TOASTS + ======================================================================== */ + +rhodecode-toast { + display: block; +} + +.noscript-error { + padding: var(--space-4); + background: var(--warning-light); + color: #92400e; + text-align: center; + font-size: 0.875rem; + border-bottom: var(--border-width) solid var(--warning); +} + +/* ======================================================================== + GENERIC FORM ELEMENTS — applies wherever forms appear + ======================================================================== */ + +input[type="text"], +input[type="password"], +input[type="email"], +input[type="url"], +input[type="search"], +input[type="number"], +select, +textarea { + font-family: var(--font-sans); + font-size: 0.875rem; + color: var(--text); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + background: var(--surface); + transition: border-color 0.15s, box-shadow 0.15s; + box-sizing: border-box; +} + +/* Native select — always single-line dropdown */ +select { + height: 36px; + appearance: auto; + cursor: pointer; +} + +/* .medium class used by RhodeCode form helpers */ +select.medium { + min-width: 200px; + max-width: 360px; +} + +input.medium { + min-width: 200px; + max-width: 360px; + width: 100%; +} + +textarea.medium { + min-width: 200px; + max-width: 480px; + width: 100%; +} + +input[type="text"]:focus, +input[type="password"]:focus, +input[type="email"]:focus, +input[type="url"]:focus, +input[type="search"]:focus, +input[type="number"]:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +/* ======================================================================== + BUTTONS — generic .btn and common button styles + ======================================================================== */ + +.btn { + display: inline-block; + padding: var(--space-2) var(--space-4); + font-family: var(--font-sans); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.5; + border: var(--border-width) solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; + text-decoration: none; + white-space: nowrap; + text-align: center; +} + +.btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.btn-primary, +.btn-action { + background: var(--accent); + color: var(--text-inverse); + border-color: var(--accent); +} + +.btn-primary:hover, +.btn-action:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + text-decoration: none; + color: var(--text-inverse); +} + +.btn-secondary, +.btn-default { + background: var(--surface); + color: var(--text); + border-color: var(--border); +} + +.btn-secondary:hover, +.btn-default:hover { + background: var(--surface-alt); + border-color: var(--border-focus); + text-decoration: none; +} + +.btn-danger { + background: var(--danger); + color: var(--text-inverse); + border-color: var(--danger); +} + +.btn-danger:hover { + background: #b91c1c; + border-color: #b91c1c; + text-decoration: none; + color: var(--text-inverse); +} + +.btn-ghost { + background: transparent; + color: var(--accent); + border-color: transparent; +} + +.btn-ghost:hover { + background: var(--accent-light); +} + +.btn-sm { + padding: var(--space-1) var(--space-3); + font-size: 0.8125rem; +} + +.btn-lg { + padding: var(--space-3) var(--space-5); + font-size: 1rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ======================================================================== + ALERTS / FLASH MESSAGES + ======================================================================== */ + +.alert { + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + font-size: 0.875rem; + border: var(--border-width) solid transparent; + margin-bottom: var(--space-3); +} + +.alert-success { + background: var(--success-light); + border-color: var(--success); + color: #065f46; +} + +.alert-warning { + background: var(--warning-light); + border-color: var(--warning); + color: #92400e; +} + +.alert-danger, +.alert-error { + background: var(--danger-light); + border-color: var(--danger); + color: #991b1b; +} + +.alert-info { + background: var(--info-light); + border-color: var(--info); + color: #1e40af; +} + +.alert .alert-dismiss { + float: right; + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + opacity: 0.6; + color: inherit; +} + +.alert .alert-dismiss:hover { + opacity: 1; +} + +/* ======================================================================== + CARDS / PANELS — used across admin, settings, etc. + ======================================================================== */ + +.card, +.panel, +.panel-default { + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + margin-bottom: var(--space-4); +} + +.card-header, +.panel-heading { + padding: var(--space-3) var(--space-4); + border-bottom: var(--border-width) solid var(--border); + font-weight: 600; + font-size: 0.9375rem; + background: var(--surface-alt); + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.card-body, +.panel-body { + padding: var(--space-4); +} + +.card-footer, +.panel-footer { + padding: var(--space-3) var(--space-4); + border-top: var(--border-width) solid var(--border); + background: var(--surface-alt); + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +/* ======================================================================== + TABLES + ======================================================================== */ + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +table th { + text-align: left; + padding: var(--space-3) var(--space-4); + background: var(--surface-alt); + font-weight: 600; + font-size: 0.8125rem; + color: var(--text-muted); + border-bottom: 2px solid var(--border); +} + +table td { + padding: var(--space-3) var(--space-4); + border-bottom: var(--border-width) solid var(--border); +} + +table tr:hover td { + background: var(--surface-alt); +} + +/* ======================================================================== + BADGES / TAGS + ======================================================================== */ + +.badge, +.tag { + display: inline-block; + padding: var(--space-1) var(--space-2); + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + border-radius: var(--radius-sm); + white-space: nowrap; +} + +.badge-accent { background: var(--accent-light); color: #1e40af; } +.badge-success { background: var(--success-light); color: #065f46; } +.badge-warning { background: var(--warning-light); color: #92400e; } +.badge-danger { background: var(--danger-light); color: #991b1b; } +.badge-neutral { background: var(--surface-alt); color: var(--text-muted); } + +/* ======================================================================== + CODE BLOCKS + ======================================================================== */ + +code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--surface-alt); + padding: 0.1em 0.3em; + border-radius: var(--radius-sm); +} + +pre { + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.6; + background: var(--surface-alt); + padding: var(--space-4); + border-radius: var(--radius-md); + overflow-x: auto; + border: var(--border-width) solid var(--border); +} + +pre code { + background: none; + padding: 0; +} + +/* ======================================================================== + DIFF BLOCKS + ======================================================================== */ + +.diff-block { + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.6; + border: var(--border-width) solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.diff-header { + padding: var(--space-3) var(--space-4); + background: var(--surface-alt); + font-weight: 600; + border-bottom: var(--border-width) solid var(--border); +} + +.diff-add { background: #dcfce7; } +.diff-del { background: #fee2e2; } + +/* ======================================================================== + SEARCH BAR — main_filter in header + ======================================================================== */ + +.searchItems { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: auto auto 1fr auto; + align-items: center; + background: rgba(255, 255, 255, 0.9); + border: var(--border-width) solid rgba(255, 255, 255, 0.3); + border-radius: var(--radius-md); + padding: 0 var(--space-2); +} + +.searchItems:focus-within { + background: var(--surface); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.searchTagIcon { + color: var(--text-muted); + padding: var(--space-1) var(--space-2); + font-size: 0.875rem; +} + +.searchTagFilter { + padding: var(--space-1); +} + +.searchTagFilter .tag { + display: inline-block; + padding: var(--space-1) var(--space-2); + background: var(--accent-light); + color: #1e40af; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; +} + +.searchTagFilter .tag a { + color: #1e40af; + margin-left: var(--space-1); +} + +.searchTagInput { + min-width: 0; +} + +.searchTagInput input.main_filter_input { + border: none; + background: transparent; + padding: var(--space-2) var(--space-2); + font-size: 0.875rem; + width: 100%; + min-width: 200px; + outline: none; + box-shadow: none; +} + +.searchTagInput input.main_filter_input:focus { + box-shadow: none; + border: none; +} + +.searchTagHelp a { + color: var(--text-muted); + font-size: 0.75rem; + padding: var(--space-1) var(--space-2); + text-decoration: none; +} + +.searchTagHidable { + /* only show when active */ +} + +#main_filter_help { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: var(--space-4); + font-size: 0.8125rem; + color: var(--text); + line-height: 1.7; + z-index: 300; + white-space: pre-line; + max-width: 500px; +} + +/* ======================================================================== + USER MENU — dropdown in header + ======================================================================== */ + +#quick_login_li { + position: relative; +} + +.user-menu.submenu { + right: 0; + min-width: 260px; + padding: 0; + display: none; +} + +#quick_login_li:hover .user-menu.submenu, +#quick_login_li:focus-within .user-menu.submenu { + display: block; +} + +#quick_login { + padding: 0; +} + +/* User header (avatar, name, email) */ +.user-menu-header { + padding: var(--space-4); + text-align: center; + border-bottom: 1px solid var(--border); +} + +.user-menu-header .big_gravatar { + margin-bottom: var(--space-2); +} + +.user-menu-header .big_gravatar img { + border-radius: 50%; + width: 48px; + height: 48px; +} + +.user-menu-header .full_name { + font-weight: 600; + font-size: 0.9375rem; + color: var(--text); +} + +.user-menu-header .email { + font-size: 0.8125rem; + color: var(--text-muted); +} + +/* Navigation links */ +.user-menu-links { + display: grid; + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); +} + +.user-menu-links a { + display: block; + padding: var(--space-2) var(--space-4); + color: var(--text); + font-size: 0.8125rem; + text-decoration: none; +} + +.user-menu-links a:hover { + background: var(--surface-alt); + color: var(--accent); + text-decoration: none; +} + +/* Bookmarks section */ +.user-menu-bookmarks { + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); +} + +.user-menu-bookmarks-header { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + padding: var(--space-1) var(--space-4); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.user-menu-bookmarks-header a { + color: var(--text-muted); + text-decoration: none; +} + +.user-menu-bookmarks-header a:hover { + color: var(--accent); +} + +.user-menu-bookmarks .bookmark-item { + display: block; + padding: var(--space-1) var(--space-4); + color: var(--text); + font-size: 0.8125rem; + text-decoration: none; +} + +.user-menu-bookmarks .bookmark-item:hover { + background: var(--surface-alt); + color: var(--accent); + text-decoration: none; +} + +.user-menu-bookmarks .bookmark-item code { + font-family: var(--font-mono); + font-size: 0.75rem; + background: var(--surface-alt); + padding: 1px 4px; + border-radius: 2px; + margin-right: var(--space-1); +} + +/* Sign out */ +.user-menu-logout { + padding: var(--space-3) var(--space-4); +} + +.user-menu-logout form { + margin: 0; +} + +.btn-signout { + display: block; + width: 100%; + padding: var(--space-2); + font-size: 0.8125rem; + font-weight: 500; + text-align: center; + color: var(--text); + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + font-family: inherit; +} + +.btn-signout:hover { + background: var(--danger-light); + color: var(--danger); + border-color: var(--danger); +} + +/* --- Action menu (+) --- */ +.action-menu.submenu { + display: none; + left: auto; + right: 0; + min-width: 220px; + padding: var(--space-2) 0; +} + +.action-menu.submenu ol { + list-style: none; + padding: 0; + margin: 0; +} + +.action-menu.submenu .submenu-title { + padding: var(--space-3) var(--space-4) var(--space-1); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.action-menu.submenu ol li a { + display: block; + padding: var(--space-2) var(--space-4); + color: var(--text); + font-size: 0.875rem; + text-decoration: none; +} + +.action-menu.submenu ol li a:hover { + background: var(--surface-alt); + text-decoration: none; +} + +li:hover > .action-menu.submenu { + display: block; +} + +/* --- Notification badge --- */ +#quick li a.empty { + color: rgba(255, 255, 255, 0.4); +} + +#quick li a:not(.empty):not(.menulink) { + color: var(--header-text); + background: var(--accent); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + min-width: 20px; + text-align: center; + text-decoration: none; +} + +/* --- Notice messages --- */ +.notice-messages-container { + position: absolute; + top: 100%; + right: 0; + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 200; + min-width: 400px; + max-width: 600px; + padding: var(--space-3); +} + +.menulabel-notice { + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + min-width: 20px; + text-align: center; + color: var(--header-text); +} + +.menulabel-notice.warning { background: var(--warning); } +.menulabel-notice.error { background: var(--danger); } +.menulabel-notice.info { background: var(--accent); } + +/* ======================================================================== + DASHBOARD / CONTENT BOXES + ======================================================================== */ + +.box { + background: var(--surface); + border-radius: var(--radius-md); +} + +.box > .title { + padding: var(--space-4) 0; + font-size: 1.125rem; + font-weight: 600; +} + +/* --- Empty state --- */ +#no_grid_data { + text-align: center; + padding: var(--space-8) var(--space-5); +} + +#no_grid_data h2 { + font-size: 1.125rem; + font-weight: 500; + color: var(--text-muted); + margin: 0; +} + +#grid_data_loading { + text-align: center; + padding: var(--space-7) var(--space-5); + color: var(--text-muted); + font-size: 0.9375rem; +} + +/* --- DataTables overrides --- */ +.dataTables_wrapper { + font-size: 0.875rem; +} + +.dataTables_paginate { + padding: var(--space-3) 0; + text-align: center; +} + +.dataTables_paginate .paginate_button { + display: inline-block; + padding: var(--space-1) var(--space-3); + margin: 0 var(--space-1); + border-radius: var(--radius-sm); + font-size: 0.8125rem; + cursor: pointer; + color: var(--text); + text-decoration: none; +} + +.dataTables_paginate .paginate_button:hover { + background: var(--surface-alt); +} + +.dataTables_paginate .paginate_button.current { + background: var(--accent); + color: var(--text-inverse); + font-weight: 600; +} + +.dataTables_paginate .paginate_button.disabled { + color: var(--text-muted); + opacity: 0.5; + cursor: default; +} + +/* --- Quick repo menu --- */ +.quick_repo_menu { + width: 30px; + text-align: center; +} + +/* ======================================================================== + ICONS (icon font overrides) + ======================================================================== */ + +[class^="icon-"], +[class*=" icon-"] { + font-style: normal; + line-height: 1; + vertical-align: middle; +} + +.icon-plus-circled { + font-size: 1.125rem; + color: rgba(255, 255, 255, 0.8); +} + +.icon-plus-circled:hover { + color: var(--header-text); +} + +.show_more { + display: inline-block; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid rgba(255, 255, 255, 0.6); + vertical-align: middle; + margin-left: var(--space-1); +} + +/* ======================================================================== + RC-SPECIFIC TABLES + ======================================================================== */ + +.rctable { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.rctable th { + text-align: left; + padding: var(--space-3) var(--space-4); + background: var(--surface-alt); + font-weight: 600; + font-size: 0.8125rem; + color: var(--text-muted); + border-bottom: 2px solid var(--border); +} + +.rctable td { + padding: var(--space-3) var(--space-4); + border-bottom: var(--border-width) solid var(--border); +} + +.rctable tr:hover td { + background: var(--surface-alt); +} + +/* ======================================================================== + SIDEBAR + CONTENT LAYOUT — admin, my_account, settings pages + ======================================================================== */ + +.sidebar-col-wrapper { + display: grid; + grid-template-columns: 220px 1fr; + gap: var(--space-5); + align-items: start; +} + +.sidebar-col-wrapper.scw-small { + grid-template-columns: 200px 1fr; +} + +.sidebar { + position: sticky; + top: 60px; +} + +.main-content-full-width { + min-width: 0; +} + +/* --- Vertical nav (nav-pills nav-stacked) — sidebar navigation --- */ +.nav.nav-pills.nav-stacked, +.sidebar ul.nav { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: var(--space-1); +} + +.nav.nav-pills.nav-stacked li a, +.sidebar ul.nav li a { + display: block; + padding: var(--space-2) var(--space-3); + color: var(--text-muted); + text-decoration: none; + font-size: 0.875rem; + border-radius: var(--radius-sm); + transition: color 0.15s, background 0.15s; +} + +.nav.nav-pills.nav-stacked li a:hover, +.sidebar ul.nav li a:hover { + color: var(--text); + background: var(--surface-alt); + text-decoration: none; +} + +.nav.nav-pills.nav-stacked li.active a, +.sidebar ul.nav li.active a { + color: var(--accent); + background: var(--accent-light); + font-weight: 500; +} + +/* ======================================================================== + BREADCRUMBS / PAGE TITLES + ======================================================================== */ + +.breadcrumbs { + font-size: 1.125rem; + font-weight: 600; + color: var(--text); + padding: 0; + margin: 0; +} + +/* ======================================================================== + BUTTON-LINKS — horizontal filter tabs (gists, etc.) + ======================================================================== */ + +.button-links { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + gap: var(--space-2); + align-items: center; +} + +.button-links li .btn { + padding: var(--space-2) var(--space-3); + font-size: 0.8125rem; + color: var(--text-muted); + background: transparent; + border: var(--border-width) solid var(--border); +} + +.button-links li .btn:hover { + color: var(--text); + background: var(--surface-alt); + border-color: var(--border-focus); + text-decoration: none; +} + +.button-links li .btn.active { + color: var(--accent); + background: var(--accent-light); + border-color: var(--accent); + font-weight: 500; +} + +/* ======================================================================== + GRID FILTER — quick filter input (gists, repos, etc.) + ======================================================================== */ + +.grid-quick-filter { + margin-top: var(--space-3); +} + +.grid-filter-box { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + max-width: 280px; +} + +.grid-filter-box-icon { + padding: var(--space-2) var(--space-3); + color: var(--text-muted); + font-size: 0.875rem; +} + +.grid-filter-box-input input { + border: none; + background: transparent; + padding: var(--space-2) var(--space-3); + font-size: 0.875rem; + width: 100%; + outline: none; + box-shadow: none; +} + +.grid-filter-box-input input:focus { + box-shadow: none; + border: none; +} + +.grid-filter-box:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +/* ======================================================================== + PROFILE / DETAIL VIEWS + ======================================================================== */ + +.profile-side { + text-align: center; +} + +/* Definition-list style profile fields */ +.dl-horizontal { + display: grid; + grid-template-columns: 140px 1fr; + gap: var(--space-2) var(--space-4); + font-size: 0.9375rem; +} + +.dl-horizontal dt { + font-weight: 600; + color: var(--text-muted); + text-align: right; + font-size: 0.8125rem; +} + +.dl-horizontal dd { + margin: 0; + color: var(--text); +} + +/* ======================================================================== + TOOLTIPS + ======================================================================== */ + +.tooltip { + position: relative; +} + +.tippy-box, +.tooltip-hovercard { + font-size: 0.8125rem; +} + +/* Native title tooltip override — can't style, but we can hide the old ones */ + +/* ======================================================================== + FORM PANELS — settings, admin edit forms + ======================================================================== */ + +.form { + max-width: 720px; +} + +.fields { + display: grid; + gap: var(--space-4); +} + +.fields .field { + display: grid; + grid-template-columns: 160px 1fr; + gap: var(--space-3); + align-items: start; +} + +.fields .field .label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text); + padding-top: var(--space-2); +} + +.fields .field .input { + min-width: 0; +} + +.fields .field .input input[type="text"], +.fields .field .input input[type="password"], +.fields .field .input input[type="email"], +.fields .field .input textarea, +.fields .field .input select { + width: 100%; + max-width: 400px; +} + +.fields .field .input .help-block { + margin-top: var(--space-1); + font-size: 0.8125rem; + color: var(--text-muted); +} + +/* ======================================================================== + MISC PAGE ELEMENTS + ======================================================================== */ + +/* Collapsible sections */ +.btn-collapse { + cursor: pointer; + color: var(--accent); + font-size: 0.8125rem; +} + +.collapsable-content { + padding: var(--space-2) 0; +} + +/* RC user display */ +.rc-user { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: var(--space-2); +} + +.rc-user .user a { + color: var(--text); + text-decoration: none; +} + +.rc-user .user a:hover { + color: var(--accent); +} + +/* Grey link actions (footer, etc.) */ +.grey-link-action { + color: var(--text-muted); + text-decoration: none; +} + +.grey-link-action:hover { + color: var(--text); +} + +/* Activation message on register */ +.activation_msg { + font-size: 0.875rem; + color: var(--text-muted); + font-style: italic; +} + +/* ======================================================================== + RESPONSIVE — sidebar collapse + ======================================================================== */ + +@media (max-width: 768px) { + .sidebar-col-wrapper, + .sidebar-col-wrapper.scw-small { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + } + + .nav.nav-pills.nav-stacked { + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + overflow-x: auto; + gap: var(--space-1); + } + + .fields .field { + grid-template-columns: 1fr; + } +} + +/* ======================================================================== + UTILITY CLASSES + ======================================================================== */ + +.pull-right { float: right; } +.pull-left { float: left; } +.clear { clear: both; } +.hidden { display: none; } +.discreet { color: var(--text-muted); } + +/* ======================================================================== + RESPONSIVE + ======================================================================== */ + +@media (max-width: 768px) { + .loginwrapper { + grid-template-columns: 1fr; + padding: var(--space-5); + } + + .auth-image-wrapper { + display: none; + } + + .header #header-inner { + grid-template-columns: auto 1fr; + } + + #main_filter { + width: 200px; + } + + #main_filter:focus { + width: 260px; + } +} + +/* ======================================================================== + STYLEGUIDE PAGE — standalone layout (sg-body, sg-layout, etc.) + Only applies on /_admin/styleguide + ======================================================================== */ + +body.sg-body { + background: var(--bg); +} + .sg-layout { display: grid; grid-template-columns: var(--sidebar-width) 1fr; @@ -165,10 +2056,6 @@ body.sg-body { max-width: var(--content-max); } -/* ======================================================================== - SECTIONS - ======================================================================== */ - .sg-section { margin-bottom: var(--space-8); } @@ -199,10 +2086,6 @@ body.sg-body { color: var(--text); } -/* ======================================================================== - COMPONENT SHOWCASE - ======================================================================== */ - .sg-showcase { padding: var(--space-5); background: var(--surface); @@ -211,12 +2094,6 @@ body.sg-body { margin-bottom: var(--space-4); } -.sg-showcase + .sg-code { - margin-top: calc(-1 * var(--space-4)); - border-top: none; - border-radius: 0 0 var(--radius-md) var(--radius-md); -} - .sg-code { background: #1e1e2e; color: #cdd6f4; @@ -230,10 +2107,6 @@ body.sg-body { white-space: pre; } -/* ======================================================================== - COLOR SWATCHES - ======================================================================== */ - .sg-colors { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); @@ -267,10 +2140,6 @@ body.sg-body { color: var(--text-muted); } -/* ======================================================================== - TYPOGRAPHY SPECIMENS - ======================================================================== */ - .sg-type-scale { display: grid; gap: var(--space-4); @@ -291,89 +2160,6 @@ body.sg-body { color: var(--text-muted); } -/* ======================================================================== - BUTTONS - ======================================================================== */ - -.btn { - display: inline-grid; - place-items: center; - padding: var(--space-2) var(--space-4); - font-family: var(--font-sans); - font-size: 0.875rem; - font-weight: 500; - line-height: 1.5; - border: var(--border-width) solid transparent; - border-radius: var(--radius-sm); - cursor: pointer; - transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; - text-decoration: none; - white-space: nowrap; -} - -.btn:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.btn-primary { - background: var(--accent); - color: var(--text-inverse); - border-color: var(--accent); -} - -.btn-primary:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); -} - -.btn-secondary { - background: var(--surface); - color: var(--text); - border-color: var(--border); -} - -.btn-secondary:hover { - background: var(--surface-alt); - border-color: var(--border-focus); -} - -.btn-danger { - background: var(--danger); - color: var(--text-inverse); - border-color: var(--danger); -} - -.btn-danger:hover { - background: #b91c1c; - border-color: #b91c1c; -} - -.btn-ghost { - background: transparent; - color: var(--accent); - border-color: transparent; -} - -.btn-ghost:hover { - background: var(--accent-light); -} - -.btn-sm { - padding: var(--space-1) var(--space-3); - font-size: 0.8125rem; -} - -.btn-lg { - padding: var(--space-3) var(--space-5); - font-size: 1rem; -} - -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - .sg-button-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, auto)); @@ -381,485 +2167,6 @@ body.sg-body { align-items: start; } -/* ======================================================================== - FORMS - ======================================================================== */ - -.form-group { - display: grid; - gap: var(--space-2); - margin-bottom: var(--space-4); -} - -.form-label { - font-size: 0.875rem; - font-weight: 500; - color: var(--text); -} - -.form-hint { - font-size: 0.8125rem; - color: var(--text-muted); -} - -.form-input, -.form-select, -.form-textarea { - padding: var(--space-2) var(--space-3); - font-family: var(--font-sans); - font-size: 0.875rem; - line-height: 1.5; - color: var(--text); - background: var(--surface); - border: var(--border-width) solid var(--border); - border-radius: var(--radius-sm); - transition: border-color 0.15s, box-shadow 0.15s; - width: 100%; -} - -.form-input:focus, -.form-select:focus, -.form-textarea:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-light); -} - -.form-input::placeholder { - color: var(--text-muted); -} - -.form-textarea { - min-height: 100px; - resize: vertical; -} - -.form-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--space-4); -} - -/* ======================================================================== - ALERTS - ======================================================================== */ - -.alert { - display: grid; - grid-template-columns: auto 1fr auto; - gap: var(--space-3); - align-items: start; - padding: var(--space-4); - border-radius: var(--radius-md); - font-size: 0.875rem; - border: var(--border-width) solid transparent; -} - -.alert-success { - background: var(--success-light); - border-color: var(--success); - color: #065f46; -} - -.alert-warning { - background: var(--warning-light); - border-color: var(--warning); - color: #92400e; -} - -.alert-danger { - background: var(--danger-light); - border-color: var(--danger); - color: #991b1b; -} - -.alert-info { - background: var(--info-light); - border-color: var(--info); - color: #1e40af; -} - -/* Capability-driven: dismiss button only visible with JS */ -.alert .alert-dismiss { - background: none; - border: none; - cursor: pointer; - font-size: 1.25rem; - line-height: 1; - opacity: 0.6; - color: inherit; -} - -.alert .alert-dismiss:hover { - opacity: 1; -} - -/* ======================================================================== - CARDS / PANELS - ======================================================================== */ - -.card { - background: var(--surface); - border: var(--border-width) solid var(--border); - border-radius: var(--radius-md); - box-shadow: var(--shadow-sm); - overflow: hidden; -} - -.card-header { - padding: var(--space-4) var(--space-5); - border-bottom: var(--border-width) solid var(--border); - font-weight: 600; - font-size: 0.9375rem; -} - -.card-body { - padding: var(--space-5); -} - -.card-footer { - padding: var(--space-4) var(--space-5); - border-top: var(--border-width) solid var(--border); - background: var(--surface-alt); -} - -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: var(--space-5); -} - -/* ======================================================================== - TABLES - ======================================================================== */ - -.table-wrap { - overflow-x: auto; - border: var(--border-width) solid var(--border); - border-radius: var(--radius-md); -} - -table.sg-table { - width: 100%; - border-collapse: collapse; - font-size: 0.875rem; -} - -table.sg-table th { - text-align: left; - padding: var(--space-3) var(--space-4); - background: var(--surface-alt); - font-weight: 600; - font-size: 0.8125rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-muted); - border-bottom: 2px solid var(--border); -} - -table.sg-table td { - padding: var(--space-3) var(--space-4); - border-bottom: var(--border-width) solid var(--border); -} - -table.sg-table tr:last-child td { - border-bottom: none; -} - -table.sg-table tr:hover td { - background: var(--surface-alt); -} - -/* ======================================================================== - BADGES / TAGS - ======================================================================== */ - -.badge { - display: inline-grid; - place-items: center; - padding: var(--space-1) var(--space-2); - font-size: 0.75rem; - font-weight: 600; - line-height: 1; - border-radius: var(--radius-sm); - white-space: nowrap; -} - -.badge-accent { background: var(--accent-light); color: #1e40af; } -.badge-success { background: var(--success-light); color: #065f46; } -.badge-warning { background: var(--warning-light); color: #92400e; } -.badge-danger { background: var(--danger-light); color: #991b1b; } -.badge-neutral { background: var(--surface-alt); color: var(--text-muted); } - -/* ======================================================================== - DETAILS / ACCORDION (native HTML, progressive enhancement) - ======================================================================== */ - -details.sg-details { - border: var(--border-width) solid var(--border); - border-radius: var(--radius-md); - margin-bottom: var(--space-3); - overflow: hidden; -} - -details.sg-details summary { - padding: var(--space-4); - cursor: pointer; - font-weight: 500; - background: var(--surface); - user-select: none; - list-style: none; -} - -details.sg-details summary::-webkit-details-marker { - display: none; -} - -details.sg-details summary::before { - content: "\25B6"; - display: inline-block; - margin-right: var(--space-3); - font-size: 0.75rem; - transition: transform 0.15s; -} - -details.sg-details[open] summary::before { - transform: rotate(90deg); -} - -details.sg-details .sg-details-body { - padding: var(--space-4); - border-top: var(--border-width) solid var(--border); -} - -/* ======================================================================== - DIALOG (native HTML) - ======================================================================== */ - -dialog.sg-dialog { - border: none; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); - padding: 0; - max-width: 480px; - width: 90vw; -} - -dialog.sg-dialog::backdrop { - background: rgba(0, 0, 0, 0.5); -} - -.sg-dialog-header { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - padding: var(--space-5); - border-bottom: var(--border-width) solid var(--border); -} - -.sg-dialog-header h3 { - font-size: 1.125rem; - font-weight: 600; -} - -.sg-dialog-body { - padding: var(--space-5); -} - -.sg-dialog-footer { - display: grid; - grid-template-columns: 1fr auto auto; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); - border-top: var(--border-width) solid var(--border); - background: var(--surface-alt); -} - -/* ======================================================================== - CODE BLOCKS - ======================================================================== */ - -code { - font-family: var(--font-mono); - font-size: 0.875em; - background: var(--surface-alt); - padding: 0.1em 0.3em; - border-radius: var(--radius-sm); -} - -pre code { - background: none; - padding: 0; -} - -/* ======================================================================== - DIFF PREVIEW - ======================================================================== */ - -.diff-block { - font-family: var(--font-mono); - font-size: 0.8125rem; - line-height: 1.6; - border: var(--border-width) solid var(--border); - border-radius: var(--radius-md); - overflow: hidden; -} - -.diff-header { - padding: var(--space-3) var(--space-4); - background: var(--surface-alt); - font-weight: 600; - border-bottom: var(--border-width) solid var(--border); -} - -.diff-line { - display: grid; - grid-template-columns: 50px 50px 1fr; - white-space: pre; -} - -.diff-line-num { - padding: 0 var(--space-3); - color: var(--text-muted); - text-align: right; - user-select: none; - border-right: var(--border-width) solid var(--border); - background: var(--surface-alt); -} - -.diff-line-content { - padding: 0 var(--space-3); -} - -.diff-add { background: #dcfce7; } -.diff-add .diff-line-num { background: #bbf7d0; } -.diff-del { background: #fee2e2; } -.diff-del .diff-line-num { background: #fecaca; } - -/* ======================================================================== - NAVIGATION - ======================================================================== */ - -.nav-bar { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: var(--space-4); - padding: var(--space-3) var(--space-5); - background: var(--surface); - border-bottom: var(--border-width) solid var(--border); -} - -.nav-bar .nav-logo { - font-weight: 700; - font-size: 1rem; - color: var(--text); - text-decoration: none; -} - -.nav-bar .nav-links { - display: grid; - grid-auto-flow: column; - grid-auto-columns: auto; - gap: var(--space-1); - justify-self: start; -} - -.nav-bar .nav-links a { - padding: var(--space-2) var(--space-3); - color: var(--text-muted); - text-decoration: none; - font-size: 0.875rem; - border-radius: var(--radius-sm); - transition: color 0.15s, background 0.15s; -} - -.nav-bar .nav-links a:hover { - color: var(--text); - background: var(--surface-alt); -} - -.nav-bar .nav-actions { - display: grid; - grid-auto-flow: column; - gap: var(--space-3); - align-items: center; -} - -/* ======================================================================== - REPO LIST ITEM - ======================================================================== */ - -.repo-item { - display: grid; - grid-template-columns: auto 1fr auto; - gap: var(--space-4); - align-items: center; - padding: var(--space-4) var(--space-5); - border-bottom: var(--border-width) solid var(--border); - transition: background 0.1s; -} - -.repo-item:hover { - background: var(--surface-alt); -} - -.repo-icon { - width: 32px; - height: 32px; - display: grid; - place-items: center; - background: var(--accent-light); - color: var(--accent); - border-radius: var(--radius-sm); - font-weight: 700; - font-size: 0.875rem; -} - -.repo-info h4 { - font-weight: 600; - font-size: 0.9375rem; -} - -.repo-info h4 a { - color: var(--accent); - text-decoration: none; -} - -.repo-info h4 a:hover { - text-decoration: underline; -} - -.repo-info p { - font-size: 0.8125rem; - color: var(--text-muted); - margin-top: var(--space-1); -} - -.repo-meta { - display: grid; - grid-auto-flow: column; - gap: var(--space-4); - font-size: 0.8125rem; - color: var(--text-muted); -} - -/* ======================================================================== - NOSCRIPT — Capability-Driven Presentation - ======================================================================== */ - -noscript .noscript-banner { - padding: var(--space-4); - background: var(--warning-light); - color: #92400e; - text-align: center; - font-size: 0.875rem; -} - -/* ======================================================================== - RESPONSIVE — Single column on narrow screens - ======================================================================== */ - @media (max-width: 768px) { .sg-layout { grid-template-columns: 1fr; @@ -876,3 +2183,1178 @@ noscript .noscript-banner { padding: var(--space-5); } } + + +/* ======================================================================== + CREATION FORMS — repo add, repo group add + ======================================================================== */ + +/* Full repo name line: [parent group] / [name input] */ +.full-repo-name-container { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: center; + gap: var(--space-2); +} + +.repo-create-spacer { + font-size: 1.25rem; + color: var(--text-muted); + font-weight: 300; + padding: 0 var(--space-1); +} + +.repo-name-input-container { + min-width: 0; +} + +.repo-name-input-container input[type="text"] { + width: 100%; +} + +.repo-header-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + margin-top: var(--space-2); +} + +.help-block-repo-create { + font-size: 0.8125rem; + color: var(--text-muted); +} + +.import-repo { + font-size: 0.8125rem; +} + +.import-repo a { + color: var(--accent); + text-decoration: none; +} + +.import-repo a:hover { + text-decoration: underline; +} + +/* Copy permissions checkbox row */ +#copy_perms { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-3); + padding: var(--space-2) var(--space-3); + background: var(--surface-alt); + border-radius: var(--radius-sm); +} + +.help-block-inline { + font-size: 0.8125rem; + color: var(--text-muted); +} + +/* Name availability check */ +.repo-check-container, +#group_check { + font-size: 0.8125rem; + margin-top: var(--space-1); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); +} + +.check-progress { + color: var(--text-muted); +} + +.check-success { + color: var(--success); +} + +.check-error { + color: var(--danger); +} + +/* Repo type radio buttons */ +.repo-type-radio { + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + gap: var(--space-4); + align-items: center; + justify-content: start; +} + +.repo-type-radio input[type="radio"] { + margin-right: var(--space-1); +} + +.repo-type-radio label { + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: var(--space-1); + cursor: pointer; + font-size: 0.875rem; +} + +.repo-type-radio .help-block { + grid-column: 1 / -1; +} + +/* ======================================================================== + PANELS — used throughout settings pages + ======================================================================== */ + +.panel { + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-md); + margin-bottom: var(--space-5); +} + +.panel-default { + border-color: var(--border); +} + +.panel-heading { + padding: var(--space-3) var(--space-4); + border-bottom: var(--border-width) solid var(--border); + background: var(--surface-alt); + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.panel-heading .panel-title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text); +} + +.panel-body { + padding: var(--space-4); +} + +.panel-footer { + padding: var(--space-3) var(--space-4); + border-top: var(--border-width) solid var(--border); + background: var(--surface-alt); + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +/* ======================================================================== + PERMISSIONS TABLE + ======================================================================== */ + +#permissions_manage { + width: 100%; + border-collapse: collapse; +} + +#permissions_manage th { + text-align: center; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-muted); + padding: var(--space-2) var(--space-3); + border-bottom: 2px solid var(--border); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +#permissions_manage th.td-owner, +#permissions_manage th.td-componentname { + text-align: left; +} + +#permissions_manage td { + padding: var(--space-2) var(--space-3); + border-bottom: var(--border-width) solid var(--border); + vertical-align: middle; +} + +#permissions_manage td.td-radio { + text-align: center; +} + +#permissions_manage td.td-radio input[type="radio"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--accent); +} + +#permissions_manage td.td-user, +#permissions_manage td.td-componentname { + font-size: 0.875rem; +} + +#permissions_manage .perm_admin_row td { + background: var(--surface-alt); + color: var(--text-muted); +} + +#permissions_manage .revoke_perm { + cursor: pointer; +} + +.user-perm-help-text { + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; +} + +.user-perm-duplicate { + opacity: 0.5; +} + +/* Recursive apply radio group */ +.label-radio { + font-size: 0.875rem; + font-weight: 500; + padding-top: var(--space-2); +} + +.radios { + display: grid; + gap: var(--space-2); +} + +.radios label { + display: inline-grid; + grid-template-columns: auto auto; + gap: var(--space-1); + align-items: center; + cursor: pointer; + font-size: 0.875rem; + margin-right: var(--space-4); +} + +/* ======================================================================== + SELECT2 OVERRIDES — for dropdowns used in create forms + ======================================================================== */ + +.select2-container .select2-choice { + border: var(--border-width) solid var(--border) !important; + border-radius: var(--radius-sm) !important; + background: var(--surface) !important; + background-image: none !important; + height: 36px !important; + line-height: 34px !important; + padding: 0 var(--space-3) !important; + font-size: 0.875rem !important; + color: var(--text) !important; + box-shadow: none !important; +} + +.select2-container .select2-choice:hover { + border-color: var(--border-focus) !important; +} + +.select2-container-active .select2-choice { + border-color: var(--accent) !important; + box-shadow: 0 0 0 3px var(--accent-light) !important; +} + +.select2-drop { + border: var(--border-width) solid var(--border) !important; + border-radius: 0 0 var(--radius-sm) var(--radius-sm) !important; + box-shadow: var(--shadow-md) !important; + background: var(--surface) !important; +} + +.select2-results { + max-height: 280px !important; +} + +.select2-results .select2-result-label { + padding: var(--space-2) var(--space-3) !important; + font-size: 0.875rem !important; + color: var(--text) !important; +} + +.select2-results .select2-highlighted { + background: var(--accent-light) !important; + color: var(--accent) !important; +} + +.drop-menu { + min-width: 180px; +} + +.drop-menu-dropdown { + min-width: 180px; +} + +/* ======================================================================== + CHECKBOX STYLING + ======================================================================== */ + +.checkboxes { + padding: var(--space-2) 0; +} + +.checkboxes input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; + vertical-align: middle; + margin-right: var(--space-2); +} + +.label-checkbox { + padding-top: var(--space-2); +} + +/* ======================================================================== + ADMIN MENU BAR — sub-navigation (repos, groups, users) + ======================================================================== */ + +.submenu-title { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + padding: var(--space-3) var(--space-4) var(--space-1); + list-style: none; +} + +/* Menu items container (quick_repo_menu) */ +.quick_repo_menu { + position: relative; +} + +.quick_repo_menu .icon-more { + cursor: pointer; + padding: var(--space-1); + color: var(--text-muted); +} + +.quick_repo_menu .icon-more:hover { + color: var(--text); +} + +.quick_repo_menu .menu_items_container { + position: absolute; + right: 0; + top: 100%; + z-index: 100; + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + min-width: 180px; +} + +.quick_repo_menu .menu_items { + list-style: none; + margin: 0; + padding: var(--space-1) 0; +} + +.quick_repo_menu .menu_items li { + padding: 0; +} + +.quick_repo_menu .menu_items li a { + display: block; + padding: var(--space-2) var(--space-3); + color: var(--text); + text-decoration: none; + font-size: 0.8125rem; +} + +.quick_repo_menu .menu_items li a:hover { + background: var(--surface-alt); + color: var(--accent); +} + +/* ======================================================================== + TITLE BAR — page title area with breadcrumbs + ======================================================================== */ + +.box > .title { + padding: var(--space-3) var(--space-4); + border-bottom: var(--border-width) solid var(--border); + margin-bottom: var(--space-4); +} + +/* ======================================================================== + ADMIN SUB-MENU (repos, repo groups, users, etc.) + ======================================================================== */ + +.admin_menu { + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + gap: 0; + list-style: none; + margin: 0; + padding: 0; + background: var(--surface); + border-bottom: var(--border-width) solid var(--border); +} + +.admin_menu li a { + display: block; + padding: var(--space-2) var(--space-4); + color: var(--text-muted); + text-decoration: none; + font-size: 0.875rem; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; +} + +.admin_menu li a:hover { + color: var(--text); + border-bottom-color: var(--border); +} + +.admin_menu li.active a { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 500; +} + +/* ======================================================================== + TEXTAREA EDITOR + ======================================================================== */ + +.textarea.editor textarea { + width: 100%; + max-width: 500px; + min-height: 100px; + resize: vertical; +} + +/* ======================================================================== + ERROR MESSAGES — form validation + ======================================================================== */ + +.error-message, +span.error-message, +.error { + color: var(--danger); + font-size: 0.8125rem; +} + +.error input, +input.error { + border-color: var(--danger) !important; +} + +/* ======================================================================== + FLASH MESSAGES — alerts + ======================================================================== */ + +.alert { + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-sm); + margin-bottom: var(--space-4); + font-size: 0.875rem; + border: var(--border-width) solid transparent; +} + +.alert-success { + background: var(--success-light); + color: var(--success); + border-color: var(--success); +} + +.alert-warning { + background: var(--warning-light); + color: var(--warning); + border-color: var(--warning); +} + +.alert-error, +.alert-danger { + background: var(--danger-light); + color: var(--danger); + border-color: var(--danger); +} + +.alert-info { + background: var(--info-light); + color: var(--info); + border-color: var(--info); +} + +/* ======================================================================== + GRAVATAR / AVATAR — user icons + ======================================================================== */ + +.gravatar { + border-radius: 50%; + vertical-align: middle; +} + +.gravatar-hierarchical { + border-radius: 50%; +} + +/* ======================================================================== + REPO GROUP / REPO LISTING — home page groups + ======================================================================== */ + +.groups_and_repos { + width: 100%; +} + +.groups_and_repos .rctable td { + padding: var(--space-2) var(--space-3); +} + +/* Repo/group name links */ +.groups_and_repos .namerow a { + color: var(--text); + font-weight: 500; + text-decoration: none; +} + +.groups_and_repos .namerow a:hover { + color: var(--accent); +} + +/* Metatag badges */ +.metatag { + display: inline-block; + padding: 1px 6px; + font-size: 0.6875rem; + font-weight: 600; + border-radius: 3px; + line-height: 1.6; +} + +/* ======================================================================== + BUTTONS — extended styles + ======================================================================== */ + +.btn.btn-primary { + background: var(--accent); + color: var(--text-inverse); + border-color: var(--accent); +} + +.btn.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn.btn-danger { + background: var(--danger); + color: var(--text-inverse); + border-color: var(--danger); +} + +.btn.btn-danger:hover { + background: #b91c1c; + border-color: #b91c1c; +} + +.btn.btn-link { + background: transparent; + border: none; + color: var(--accent); + padding: var(--space-1) var(--space-2); + cursor: pointer; +} + +.btn.btn-link:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +.btn.btn-link.btn-danger { + color: var(--danger); + background: transparent; +} + +.btn.btn-link.btn-danger:hover { + color: #b91c1c; +} + +/* Button row in forms */ +.buttons { + padding-top: var(--space-4); + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + gap: var(--space-3); + justify-content: start; +} + +/* ======================================================================== + REPO GROUP EDIT TABS — settings pages + ======================================================================== */ + +.repo-group-title, +.repo-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text); + margin-bottom: var(--space-3); +} + +/* ======================================================================== + NOSCRIPT ERROR + ======================================================================== */ + +.noscript-error { + background: var(--warning-light); + color: var(--warning); + padding: var(--space-4); + text-align: center; + font-size: 0.875rem; + font-weight: 500; +} + +/* ======================================================================== + DATA / EMPTY STATES + ======================================================================== */ + +.empty-state { + text-align: center; + padding: var(--space-7) var(--space-5); + color: var(--text-muted); +} + +.empty-state h3 { + font-size: 1.125rem; + margin-bottom: var(--space-2); +} + +.empty-state p { + font-size: 0.875rem; +} + +/* ======================================================================== + DEFORM / RCFORM — schema-based forms (repo group settings edit) + ======================================================================== */ + +.deform .form-group, +.rc-form .form-group { + display: grid; + grid-template-columns: 180px 1fr; + gap: var(--space-3); + align-items: start; + margin-bottom: var(--space-4); +} + +.deform .form-group .control-label, +.rc-form .form-group .control-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text); + padding-top: var(--space-2); + text-align: right; +} + +.deform .form-group .controls, +.rc-form .form-group .controls { + min-width: 0; +} + +.deform .form-group .controls input[type="text"], +.deform .form-group .controls input[type="email"], +.deform .form-group .controls textarea, +.deform .form-group .controls select, +.rc-form .form-group .controls input[type="text"], +.rc-form .form-group .controls input[type="email"], +.rc-form .form-group .controls textarea, +.rc-form .form-group .controls select { + width: 100%; + max-width: 400px; +} + +/* ======================================================================== + QUICK PATH CREATE — create nested groups with paths like org/team/project + ======================================================================== */ + +.path-create-help { + background: var(--info-light); + border: var(--border-width) solid var(--accent); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + margin-top: var(--space-2); + font-size: 0.8125rem; + color: var(--text); +} + +.path-create-help code { + background: var(--surface); + padding: 1px 4px; + border-radius: 2px; + font-family: var(--font-mono); + font-size: 0.75rem; +} + +/* ======================================================================== + SELECT2 — override the jQuery Select2 plugin appearance + The old RhodeCode uses Select2 for dropdowns. These styles make it + blend with our design without removing the jQuery dependency yet. + ======================================================================== */ + +/* Hide the inner select2 container and show native select when select2 fails */ +.select2-container { + display: inline-block; + vertical-align: middle; +} + +.select2-container .select2-choice { + height: 36px !important; + line-height: 34px !important; + border: var(--border-width) solid var(--border) !important; + border-radius: var(--radius-sm) !important; + background: var(--surface) !important; + background-image: none !important; + box-shadow: none !important; + padding: 0 var(--space-3) !important; + font-size: 0.875rem !important; + color: var(--text) !important; +} + +.select2-container .select2-choice:hover { + border-color: var(--text-muted) !important; +} + +.select2-container-active .select2-choice, +.select2-container .select2-choice:focus { + border-color: var(--accent) !important; + box-shadow: 0 0 0 3px var(--accent-light) !important; +} + +.select2-container .select2-choice .select2-arrow { + background: none !important; + border-left: none !important; + width: 24px !important; +} + +.select2-drop { + border: var(--border-width) solid var(--border) !important; + border-top: none !important; + border-radius: 0 0 var(--radius-sm) var(--radius-sm) !important; + box-shadow: var(--shadow-md) !important; + background: var(--surface) !important; +} + +.select2-results { + max-height: 300px !important; + padding: var(--space-1) 0 !important; + margin: 0 !important; +} + +.select2-results .select2-result-label { + padding: var(--space-2) var(--space-3) !important; + font-size: 0.875rem !important; +} + +.select2-results .select2-highlighted { + background: var(--accent-light) !important; + color: var(--accent) !important; +} + +.select2-search input { + border: var(--border-width) solid var(--border) !important; + border-radius: var(--radius-sm) !important; + font-size: 0.875rem !important; + padding: var(--space-2) var(--space-3) !important; +} + +/* ======================================================================== + DATA GRID / TABLE CONTROLS — admin list pages + ======================================================================== */ + +/* Quick filter input on list pages */ +.dataTables_filter, +.grid-quick-filter-box { + margin-bottom: var(--space-3); +} + +.dataTables_filter input, +input.q_filter_box { + padding: var(--space-2) var(--space-3); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + background: var(--surface); + min-width: 200px; +} + +.dataTables_filter input:focus, +input.q_filter_box:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); + outline: none; +} + +/* Info text (e.g. "0 user groups (0 inactive)") */ +.dataTables_info, +.grid-data-info { + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: var(--space-3); +} + +/* Pagination */ +.dataTables_paginate { + margin-top: var(--space-3); + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + gap: var(--space-1); + justify-content: center; +} + +.dataTables_paginate .paginate_button { + display: inline-block; + padding: var(--space-1) var(--space-3); + font-size: 0.8125rem; + color: var(--text-muted); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + cursor: pointer; + text-decoration: none; +} + +.dataTables_paginate .paginate_button:hover { + color: var(--text); + border-color: var(--accent); + text-decoration: none; +} + +.dataTables_paginate .paginate_button.current { + color: var(--text-inverse); + background: var(--accent); + border-color: var(--accent); +} + +.dataTables_paginate .paginate_button.disabled { + opacity: 0.4; + cursor: default; +} + +/* ======================================================================== + ADMIN LIST PAGES — repositories, users, groups list layouts + ======================================================================== */ + +/* Grid header row with filter + count */ +.panel-body > ul, +.panel-body > ol { + list-style: none; + padding: 0; + margin: 0; +} + +/* RC table for list views */ +table.rctable { + width: 100%; + border-collapse: collapse; +} + +table.rctable th { + text-align: left; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-muted); + padding: var(--space-2) var(--space-3); + border-bottom: 2px solid var(--border); + white-space: nowrap; +} + +table.rctable td { + padding: var(--space-2) var(--space-3); + border-bottom: var(--border-width) solid var(--border); + font-size: 0.875rem; + vertical-align: middle; +} + +table.rctable tr:hover td { + background: var(--surface-alt); +} + +table.rctable td.td-action { + text-align: right; + white-space: nowrap; +} + +/* Empty state messages in tables */ +table.rctable td[colspan] { + text-align: center; + color: var(--text-muted); + padding: var(--space-5) var(--space-3); + font-style: italic; +} + +/* Action links and items inside tables */ +.grid_edit, +.grid_delete { + font-size: 0.8125rem; +} + +/* ======================================================================== + HEADER SEARCH HELP DROPDOWN + ======================================================================== */ + +#main_filter_help { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--surface); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + padding: var(--space-3) var(--space-4); + font-size: 0.8125rem; + color: var(--text); + z-index: 300; + max-width: 500px; + line-height: 1.6; +} + +#main_filter_help strong { + color: var(--accent); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +/* ======================================================================== + NOTIFICATION BADGE — top bar + ======================================================================== */ + +.header .main_nav > li > a.empty { + opacity: 0.4; +} + +/* ======================================================================== + ICON OVERRIDES — ensure consistent sizing + ======================================================================== */ + +i[class^="icon-"], +i[class*=" icon-"] { + font-style: normal; +} + +/* ======================================================================== + HELPER / UTILITY TEXT + ======================================================================== */ + +.help-block { + font-size: 0.8125rem; + color: var(--text-muted); + margin-top: var(--space-1); +} + +.help-block pre { + font-family: var(--font-mono); + font-size: 0.75rem; + background: var(--surface-alt); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + white-space: pre-wrap; + overflow-x: auto; +} + +/* ======================================================================== + TABLE HEADER ROW — used on admin list pages with filter + button + ======================================================================== */ + +.table-header-row { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +/* ======================================================================== + BOX TITLE / PAGE TITLE — used above content sections + ======================================================================== */ + +.box > .title { + padding: var(--space-2) 0; + margin-bottom: var(--space-3); + border-bottom: none; +} + +.box > .title h5 { + font-size: 1rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +/* ======================================================================== + LINKS LIST (non-nav) — used in content for action links + ======================================================================== */ + +.links-block { + list-style: none; + padding: 0; + margin: 0; +} + +/* ======================================================================== + FORM IMPROVEMENTS — tighter spacing for create/edit forms + ======================================================================== */ + +.form .fields .field .label label { + font-weight: 500; + font-size: 0.875rem; + color: var(--text); +} + +.form .fields .buttons { + grid-column: 1 / -1; +} + +/* Fix formencode error styling */ +.form .error-message, +.form span.error-message { + display: block; + color: var(--danger); + font-size: 0.8125rem; + margin-top: var(--space-1); +} + +/* ======================================================================== + FOOTER — multi-column site footer + ======================================================================== */ + +.site-footer { + background: var(--surface); + border-top: 1px solid var(--border); + margin-top: var(--space-6); + padding: var(--space-6) var(--space-5) var(--space-4); +} + +.footer-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: var(--space-6); + max-width: 1200px; + margin: 0 auto var(--space-5); +} + +.footer-brand { + display: grid; + gap: var(--space-2); + align-content: start; +} + +.footer-logo { + font-size: 1.25rem; + font-weight: 700; + color: var(--text); + text-decoration: none; +} + +.footer-logo:hover { + color: var(--accent); +} + +.footer-tagline { + color: var(--text-muted); + font-size: 0.8125rem; + line-height: 1.5; + max-width: 300px; + margin: 0; +} + +.footer-version { + color: var(--text-muted); + font-size: 0.75rem; + margin: 0; +} + +.footer-group { + display: grid; + gap: var(--space-3); + align-content: start; +} + +.footer-group-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.footer-links { + display: grid; + gap: var(--space-2); +} + +.footer-links a { + color: var(--text-muted); + font-size: 0.8125rem; + text-decoration: none; +} + +.footer-links a:hover { + color: var(--accent); +} + +.footer-bottom { + text-align: center; + color: var(--text-muted); + font-size: 0.75rem; + padding-top: var(--space-4); + border-top: 1px solid var(--border); + max-width: 1200px; + margin: 0 auto; +} + +.footer-instance { + margin-left: var(--space-2); + opacity: 0.6; +} + +/* ======================================================================== + RESPONSIVE — page sidebar collapse + ======================================================================== */ + +@media (max-width: 900px) { + .page-with-sidebar { + grid-template-columns: 1fr; + } + + .page-sidebar { + position: static; + border-right: none; + border-bottom: var(--border-width) solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + margin-bottom: var(--space-3); + } + + .sidebar-section { + display: none; + } + + .sidebar-section:first-child { + display: block; + } + + .sidebar-nav { + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + gap: var(--space-1); + overflow-x: auto; + } + + .footer-grid { + grid-template-columns: 1fr 1fr; + gap: var(--space-4); + } + + .footer-brand { + grid-column: 1 / -1; + } +} diff --git a/rhodecode/public/js/rhodecode/base/keyboard-bindings.js b/rhodecode/public/js/rhodecode/base/keyboard-bindings.js index 41914afa..81b65810 100644 --- a/rhodecode/public/js/rhodecode/base/keyboard-bindings.js +++ b/rhodecode/public/js/rhodecode/base/keyboard-bindings.js @@ -13,10 +13,10 @@ function setRCMouseBindings(repoName, repoLandingRev) { return element.tagName == 'INPUT' || element.tagName == 'SELECT' || element.tagName == 'TEXTAREA' || element.isContentEditable; }; - // general help "?" - Mousetrap.bind(['?'], function(e) { - $('#help_kb').modal({}); - }); + // general help "?" — disabled, modal requires jQuery+Bootstrap + // Mousetrap.bind(['?'], function(e) { + // $('#help_kb').modal({}); + // }); // / open the quick filter Mousetrap.bind(['/'], function(e) { diff --git a/rhodecode/templates/admin/main.mako b/rhodecode/templates/admin/main.mako index b78424b2..e64a2195 100644 --- a/rhodecode/templates/admin/main.mako +++ b/rhodecode/templates/admin/main.mako @@ -1,7 +1,7 @@ <%inherit file="/base/base.mako"/> <%def name="title()"> - ${_('Settings administration')} + ${_('Admin')} %if c.rhodecode_name: · ${h.branding(c.rhodecode_name)} %endif @@ -17,51 +17,43 @@ ${self.admin_menu()} -<%def name="side_bar_nav()"> - - - -<%def name="main_content()"> -

${_('Administration area')}

- - - - - - - - - - - - - - - - - -
${_('Repositories under administration')}${len(c.auth_user.repositories_admin)} - % if c.can_create_repo: - ${_('Add Repository')} - % endif -
${_('Repository groups under administration')}${len(c.auth_user.repository_groups_admin)} - % if c.can_create_repo_group: - ${_('Add Repository Group')} - % endif -
${_('User groups under administration')}${len(c.auth_user.user_groups_admin)} - % if c.can_create_user_group: - ${_('Add User Group')} - % endif -
- - <%def name="main()">
+
+

${_('Admin Dashboard')}

- ##main -
- ${self.main_content()} + + +
+

${_('Quick Actions')}

+
+ % if c.can_create_repo: + ${_('New Repository')} + % endif + % if c.can_create_repo_group: + ${_('New Repo Group')} + % endif + % if c.can_create_user_group: + ${_('New User Group')} + % endif + % if c.is_super_admin: + ${_('Settings')} + % endif +
+
- - \ No newline at end of file + diff --git a/rhodecode/templates/admin/my_account/my_account.mako b/rhodecode/templates/admin/my_account/my_account.mako index ad0fd217..bc45b339 100644 --- a/rhodecode/templates/admin/my_account/my_account.mako +++ b/rhodecode/templates/admin/my_account/my_account.mako @@ -27,8 +27,6 @@
- ${_('Parent Repository Group / Repository Group Name')}. + ${_('Parent / Group Name')} — use / to create nested groups, e.g. org/team/project
diff --git a/rhodecode/templates/admin/repos/repo_add_base.mako b/rhodecode/templates/admin/repos/repo_add_base.mako index fe32aae4..817d3e8d 100644 --- a/rhodecode/templates/admin/repos/repo_add_base.mako +++ b/rhodecode/templates/admin/repos/repo_add_base.mako @@ -27,10 +27,10 @@ ${h.secure_form(h.route_path('repo_create'), request=request)}
- ${_('Repository Group / Repository Name')}. + ${_('Group / Repository Name')}
diff --git a/rhodecode/templates/base/base.mako b/rhodecode/templates/base/base.mako index 0f8843d9..d925d7b6 100644 --- a/rhodecode/templates/base/base.mako +++ b/rhodecode/templates/base/base.mako @@ -26,7 +26,6 @@ - ${self.menu_bar_subnav()} @@ -34,8 +33,19 @@ -
- ${next.main()} +
+ <% + not_anon = c.rhodecode_user.username != h.DEFAULT_USER + has_sidebar = hasattr(next, 'page_sidebar') or not_anon + %> + % if has_sidebar: + + % endif +
+ ${next.main()} +
@@ -44,38 +54,61 @@ - + + ### MAKO DEFS ### @@ -83,6 +116,33 @@ <%def name="menu_bar_subnav()"> +<%def name="page_sidebar()"> +<% +not_anon = c.rhodecode_user.username != h.DEFAULT_USER +can_create_repos = c.is_super_admin or c.can_create_repo +can_create_repo_groups = c.is_super_admin or c.can_create_repo_group +%> + +% if not_anon: + + +## Admin subnav rendered into sidebar (populated by admin page templates) +${self.menu_bar_subnav()} + +% endif + + <%def name="breadcrumbs(class_='breadcrumbs')">
${self.breadcrumbs_links()} @@ -90,63 +150,60 @@ <%def name="admin_menu(active=None)"> +% if c.is_super_admin: + -
-
-
-
-
- % if c.is_super_admin: - ${_('Super-admin Panel')} - % else: - ${_('Delegated Admin Panel')} - % endif -
-
-
+ -
+
+% endif <%def name="dt_info_panel(elements)"> @@ -469,7 +526,7 @@ % if c.is_super_admin or group_admin:
  • - +
  • % endif @@ -610,81 +667,53 @@