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')}
- ${_('Import an existing repository.')} + ${_('Import existing')}
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 @@