diff --git a/.github/workflows/dependabot-approve.yml b/.github/workflows/dependabot-approve.yml new file mode 100644 index 000000000..06f0b638a --- /dev/null +++ b/.github/workflows/dependabot-approve.yml @@ -0,0 +1,22 @@ +name: Auto-approve Dependabot PRs +on: + schedule: + - cron: "7 * * * *" + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + auto-approve: + name: Auto-approve minor and patch updates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: koj-co/dependabot-pr-action@master + with: + token: ${{ secrets.GITHUB_TOKEN }} + approve-minor: true + approve-patch: true + diff --git a/.mergify.yml b/.mergify.yml index 0c4c007e9..550647d95 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,4 +1,5 @@ pull_request_rules: + - name: Automatic merge on approval actions: merge: @@ -28,3 +29,17 @@ pull_request_rules: - status-success=DCO - status-success=CI on f32 - status-success=CI on f33 + +- name: Automatic merge Dependabot PRs + actions: + merge: + method: rebase + rebase_fallback: null + strict: true + conditions: + - label!=WIP + - author=dependabot[bot] + - approved-reviews-by=github-actions[bot] + - status-success=DCO + - status-success=CI on f32 + - status-success=CI on f33 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..2f862dd5e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,72 @@ +repos: + # - repo: https://github.com/asottile/pyupgrade + # rev: v2.15.0 + # hooks: + # - id: pyupgrade + # args: + # - --py36-plus + + - repo: https://github.com/psf/black + rev: 21.5b2 + hooks: + - id: black + language_version: python3 + args: ["-c"] + + - repo: https://github.com/pycqa/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + + - repo: https://github.com/pycqa/isort + rev: 5.8.0 + hooks: + - id: isort + args: ["-c"] + + - repo: https://github.com/Lucas-C/pre-commit-hooks-bandit + rev: v1.0.5 + hooks: + - id: python-bandit-vulnerability-check + alias: bandit + args: ["-r", "noggin/", "-x", "noggin/tests/", "-ll"] + # - repo: local + # hooks: + # - id: bandit-local + # name: bandit + # entry: bandit + # args: ["-r", "noggin/", "-x", "noggin/tests/", "-ll"] + # pass_filenames: false + # language: system + + - repo: https://github.com/myint/rstcheck + rev: 3f92957 + hooks: + - id: rstcheck + args: ["-r", "docs"] + additional_dependencies: [sphinx] + + - repo: https://github.com/Lucas-C/pre-commit-hooks-safety + rev: v1.2.1 + hooks: + - id: python-safety-dependencies-check + alias: safety + additional_dependencies: ["poetry"] + # - repo: local + # hooks: + # - id: safety-local + # name: safety + # entry: safety + # args: [check, --full-report] + # language: system + # pass_filenames: false + + + - repo: local + hooks: + - id: liccheck + name: liccheck + entry: ./devel/run-liccheck.sh + files: "(pyproject.toml|poetry.lock)" + pass_filenames: false + language: script diff --git a/Vagrantfile b/Vagrantfile index ae14e94a0..627c7b59c 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -13,6 +13,7 @@ Vagrant.configure(2) do |config| freeipa.vm.hostname = "ipa.noggin.test" freeipa.hostmanager.aliases = ("kerberos.noggin.test") freeipa.vm.synced_folder '.', '/vagrant', disabled: true + freeipa.vm.synced_folder ".", "/home/vagrant/noggin", type: "sshfs" freeipa.vm.provider :libvirt do |libvirt| libvirt.cpus = 2 diff --git a/devel/ansible/roles/common/vars/main.yml b/devel/ansible/roles/common/vars/main.yml index 224d8384c..c98cacc20 100644 --- a/devel/ansible/roles/common/vars/main.yml +++ b/devel/ansible/roles/common/vars/main.yml @@ -1,5 +1,5 @@ --- ipa_admin_user: admin ipa_admin_password: adminPassw0rd! -krb_master_password: adminPassw0rd! +krb_main_password: adminPassw0rd! krb_realm: NOGGIN.TEST diff --git a/devel/ansible/roles/freeipa/tasks/main.yml b/devel/ansible/roles/freeipa/tasks/main.yml index e4cd55f3d..ca253bda0 100644 --- a/devel/ansible/roles/freeipa/tasks/main.yml +++ b/devel/ansible/roles/freeipa/tasks/main.yml @@ -24,7 +24,7 @@ changed_when: "False" - name: install freeipa server - shell: umask 022; ipa-server-install -a {{ ipa_admin_password }} --hostname=ipa.noggin.test -r {{ krb_realm }} -p {{ krb_master_password }} -n noggin.test -U + shell: umask 022; ipa-server-install -a {{ ipa_admin_password }} --hostname=ipa.noggin.test -r {{ krb_realm }} -p {{ krb_main_password }} -n noggin.test -U - name: get freeipa-fas git: diff --git a/devel/ansible/roles/freeipa/templates/create_dummy_data.py b/devel/ansible/roles/freeipa/templates/create_dummy_data.py index 0d711d555..aad5c17d0 100644 --- a/devel/ansible/roles/freeipa/templates/create_dummy_data.py +++ b/devel/ansible/roles/freeipa/templates/create_dummy_data.py @@ -31,21 +31,21 @@ def rando(percentage): groups["sysadmin-" + word] = 5 groups["z-git-" + word] = 5 -ipa = python_freeipa.ClientLegacy(host="ipa.noggin.test", verify_ssl="/etc/ipa/ca.crt") +ipa = python_freeipa.ClientMeta(host="ipa.noggin.test", verify_ssl="/etc/ipa/ca.crt") ipa.login("{{ ipa_admin_user }}", "{{ ipa_admin_password }}") -untouched_ipa = python_freeipa.ClientLegacy( +untouched_ipa = python_freeipa.ClientMeta( host="ipa.noggin.test", verify_ssl="/etc/ipa/ca.crt" ) -ipa._request("fasagreement_add", "FPCA", {"description": "This ia the FPCA agreement"}) +ipa._request("fasagreement_add", "FPCA", {"description": "This is the FPCA agreement"}) for group in groups.keys(): print(f"adding group: {group}") - ipa.group_add(group, f"A group for {group}", fasgroup=True) + ipa.group_add(group, o_description=f"A group for {group}", fasgroup=True) ipa._request("fasagreement_add_group", "FPCA", {"group": group}) -ipa.group_add("general", "A group for general stuff", fasgroup=True) +ipa.group_add("general", o_description="A group for general stuff", fasgroup=True) for x in range(100): @@ -57,11 +57,11 @@ def rando(percentage): try: ipa.user_add( username, - firstName, - lastName, - fullname, - disabled=False, - user_password=USER_PASSWORD, + o_givenname=firstName, + o_sn=lastName, + o_cn=fullname, + o_nsaccountlock=False, + o_userpassword=USER_PASSWORD, fasircnick=[username, username + "_"], faslocale="en-US", fastimezone="Australia/Brisbane", @@ -78,17 +78,24 @@ def rando(percentage): ipa._request("fasagreement_add_user", "FPCA", {"user": username}) has_signed_fpca = True else: - ipa.group_add_member("general", username) + ipa.group_add_member("general", o_user=username) # add to groups for groupname, chance in groups.items(): if rando(chance) and has_signed_fpca: - ipa.group_add_member(groupname, username) + ipa.group_add_member(groupname, o_user=username) # add member manager (sponsor) if rando(30): - ipa._request( - "group_add_member_manager", groupname, {"user": username} - ) + ipa.group_add_member_manager(groupname, o_user=username) except python_freeipa.exceptions.FreeIPAError as e: print(e) + + +# Create the stage user managers role and assign it to the infra group +ipa.privilege_add("Stage User Managers", o_description="Manage registering users in Noggin") +for perm in ("System: Read Stage Users", "System: Modify Stage User", "System: Remove Stage User"): + ipa.privilege_add_permission("Stage User Managers", o_permission=perm) +ipa.role_add("Stage User Managers", o_description="Manage registering users in Noggin") +ipa.role_add_privilege("Stage User Managers", o_privilege="Stage User Managers") +ipa.role_add_member("Stage User Managers", o_group="infra") diff --git a/devel/ansible/roles/noggin/files/noggin.service b/devel/ansible/roles/noggin/files/noggin.service index 79ec8a27c..67c42cd98 100644 --- a/devel/ansible/roles/noggin/files/noggin.service +++ b/devel/ansible/roles/noggin/files/noggin.service @@ -7,6 +7,7 @@ Wants=network-online.target Environment=FLASK_APP=/home/vagrant/noggin/noggin/app.py Environment=NOGGIN_CONFIG_PATH=/home/vagrant/noggin.cfg Environment=FLASK_DEBUG=1 +Environment=PYTHONUNBUFFERED=1 User=vagrant WorkingDirectory=/home/vagrant/noggin/noggin ExecStart=poetry run flask run -h 0.0.0.0 diff --git a/devel/run-liccheck.sh b/devel/run-liccheck.sh new file mode 100755 index 000000000..15fafe33e --- /dev/null +++ b/devel/run-liccheck.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +trap 'rm -f "$TMPFILE"' EXIT + +set -e + +TMPFILE=$(mktemp -t noggin-requirements-XXXXXX.txt) + +# Note: we can't use poetry export because it isn't smart enough with conditional dependencies: +# flake8 requires importlib_metadata on python < 3.8, so it's not installed, but it's exported +# and liccheck crashes on packages listed in the req file but not installed. +# poetry export --dev -f requirements.txt -o $TMPFILE + +poetry run pip freeze --exclude-editable --isolated > $TMPFILE +poetry run liccheck -r $TMPFILE diff --git a/docs/contributing.rst b/docs/contributing.rst index 106f6b1dd..1967ceaf7 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -196,7 +196,7 @@ When cutting a new release, follow these steps: #. Run ``poetry install`` to update the version in the metadata #. Add missing authors to the release notes fragments by changing to the ``news`` directory and running the ``get-authors.py`` script, but check for duplicates and errors -#. Generate the release notes by running ``towncrier`` (in the base directory) +#. Generate the release notes by running ``poetry run towncrier`` (in the base directory) #. Adjust the release notes in ``docs/release_notes.rst``. #. Generate the docs with ``tox -e docs`` and check them in ``docs/_build/html``. #. Commit the changes diff --git a/docs/installation.rst b/docs/installation.rst index f68bbc6f5..f0a3cfab9 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -3,3 +3,23 @@ Installation ============ .. note:: **TODO**: Cover end-user installation here. + + +IPA settings +============ + +If you want to be able to manage registering users, you need to setup the corresponding role and privilege in IPA. + +First, create a privilege containing the permissions needed to manage stage users:: + + ipa privilege-add "Stage User Managers" --desc "Manage registering users in Noggin" + ipa privilege-add-permission "Stage User Managers" --permissions "System: Read Stage Users" --permissions "System: Modify Stage User" --permissions "System: Remove Stage User" + +Then, create a role associated with this privilege:: + + ipa role-add "Stage User Managers" --desc "Manage registering users in Noggin" + ipa role-add-privilege "Stage User Managers" --privileges "Stage User Managers" + +Finally, if your administrators group is called ``sysadmin``, give people in the ``sysadmin`` group the role to manage registering users:: + + ipa role-add-member "Stage User Managers" --groups sysadmin diff --git a/docs/release_notes.rst b/docs/release_notes.rst index f1d00414c..9bcae3385 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -4,6 +4,50 @@ Release notes .. towncrier release notes start +v1.3.0 +====== + +Released on 2021-07-21. + +Features +^^^^^^^^ + +* Add a page to manage registering users (:pr:`672`). +* Allow template override with a custom directory, see the + ``TEMPLATES_CUSTOM_DIRECTORIES`` configration value (:pr:`701`). +* Allow users to declare their Matrix IDs in addition to the IRC nicknames + (:issue:`248`). +* Display on users' profiles the agreements they have signed (:issue:`576`). +* Validate email addresses when changed in the ``mail`` or ``rhbz_mail`` + attributes (:issue:`610`). +* Allow users to select multiple pronouns (:issue:`646`). + +Bug Fixes +^^^^^^^^^ + +* Don't tell users signing up that their username is already taken when it can + be the email address (:pr:`665`). +* Add the ``for`` attribute to checkbox labels (:issue:`658`). + +Development Improvements +^^^^^^^^^^^^^^^^^^^^^^^^ + +* Start using `pre-commit `_ to run the simple + checkers (linters, formatters, security checks). Run ``poetry install`` to + install the new dependencies, and then run ``pre-commit install`` to setup + the git hook. Also add the `safety `_ tool + (:pr:`659`). + +Contributors +^^^^^^^^^^^^ + +Many thanks to the contributors of bug reports, pull requests, and pull request +reviews for this release: + +* Aurélien Bompard +* Calvin Goodale + + v1.2.0 ====== Released on 2021-05-18. @@ -14,6 +58,8 @@ Features * Display the version in the page footer (:issue:`592`). * Allow sponsors to resign from their position in the group (:issue:`599`). +* Disallow login and register with mixed-case usernames (:issue:`594`). +* Add information in the validation email (:issue:`629`). Bug Fixes ^^^^^^^^^ diff --git a/news/573.bug b/news/573.bug deleted file mode 100644 index ff0e7000d..000000000 --- a/news/573.bug +++ /dev/null @@ -1 +0,0 @@ -Lowercase the username in Forgot Password Ask controller \ No newline at end of file diff --git a/news/592.feature b/news/592.feature deleted file mode 100644 index 77564c3d8..000000000 --- a/news/592.feature +++ /dev/null @@ -1 +0,0 @@ -Display the version in the page footer diff --git a/news/593.bug b/news/593.bug deleted file mode 100644 index 42d415ec8..000000000 --- a/news/593.bug +++ /dev/null @@ -1 +0,0 @@ -Skipped autocomplete in OTP fields \ No newline at end of file diff --git a/news/599.feature b/news/599.feature deleted file mode 100644 index b466a18da..000000000 --- a/news/599.feature +++ /dev/null @@ -1 +0,0 @@ -Allow sponsors to resign from their position in the group diff --git a/news/_template.rst b/news/_template.rst.j2 similarity index 96% rename from news/_template.rst rename to news/_template.rst.j2 index b9206cc3f..c5b20fd29 100644 --- a/news/_template.rst +++ b/news/_template.rst.j2 @@ -8,9 +8,11 @@ {%- endif -%} {%- endmacro -%} +{{ top_line }} +{{ top_underline * ((top_line)|length)}} + Released on {{ versiondata.date }}. This is a {major|feature|bugfix} release that adds [short summary]. - {% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} {{ underline * section|length }}{% set underline = underlines[1] %} diff --git a/noggin.cfg.example b/noggin.cfg.example index 6a445df62..acfb93692 100644 --- a/noggin.cfg.example +++ b/noggin.cfg.example @@ -61,7 +61,7 @@ MAIL_DEFAULT_SENDER = "Noggin " # https://pythonhosted.org/Flask-Mail/#configuring-flask-mail MAIL_SUPPRESS_SEND = True -# email domains that a user cannot use to register or change to +# Email domains that a user cannot use to register or change to # MAIL_DOMAIN_BLOCKLIST = ['fedoraproject.org'] # URL for the avatar service, typically either @@ -73,6 +73,35 @@ MAIL_SUPPRESS_SEND = True # other options are: "mp", "identicon", "monsterid", "wavatar", "retro", "robohash" # AVATAR_DEFAULT_TYPE = "robohash" +# Probe endpoints for OpenShift +# https://github.com/fedora-infra/flask-healthz/ +# HEALTHZ = { +# "live": "noggin.controller.root.liveness", +# "ready": "noggin.controller.root.readiness", +# } + +# Page size when paginating results +# PAGE_SIZE = 30 + +# IPA role allowed to modify and delete stage users. The stage users admin page +# will only be accessible to users that have this role. +# STAGE_USERS_ROLE = "Stage User Managers" + +# Additional directories to look up folders in. The templates in these directories +# will override the app's template with the same filename. You can also create the +# following templates to insert HTML in the output: +# - `after-navbar.html`: will be inserted between the navbar and the main content +# on every page +# - `before-footer.html`: will be inserted between the main content and the footer +# on every page +# - `head.html`: will be inserted at the end of the tag on every page +# TEMPLATES_CUSTOM_DIRECTORIES = [] + +# The following sources will be added to the Content Security Policy for images. +# This can be useful if you want to add images in custom templates. +# https://content-security-policy.com/img-src/ +# ACCEPT_IMAGES_FROM = [] + # Spam checking # BASSET_URL = None # SPAMCHECK_TOKEN_EXPIRATION = 60 # in minutes diff --git a/noggin/app.py b/noggin/app.py index 79bf2813d..092079be7 100644 --- a/noggin/app.py +++ b/noggin/app.py @@ -2,6 +2,7 @@ from logging.config import dictConfig import flask_talisman +import jinja2 from flask import Flask from flask_healthz import healthz from flask_mail import Mail @@ -14,6 +15,7 @@ from noggin.security.ipa_admin import IPAAdmin from noggin.themes import Theme from noggin.utility import import_all +from noggin.utility.templates import format_nickname # Forms @@ -54,6 +56,15 @@ def create_app(config=None): if app.config.get("TEMPLATES_AUTO_RELOAD"): app.jinja_env.auto_reload = True + # Custom template folders + if app.config["TEMPLATES_CUSTOM_DIRECTORIES"]: + app.jinja_loader = jinja2.ChoiceLoader( + [ + jinja2.FileSystemLoader(app.config["TEMPLATES_CUSTOM_DIRECTORIES"]), + app.jinja_loader, + ] + ) + # Logging if app.config.get("LOGGING"): dictConfig(app.config["LOGGING"]) @@ -82,11 +93,19 @@ def create_app(config=None): # https://csp.withgoogle.com/docs/strict-csp.html#example "'strict-dynamic'", ], - "img-src": ["'self'", "seccdn.libravatar.org"], + "img-src": ["'self'", "seccdn.libravatar.org"] + + app.config["ACCEPT_IMAGES_FROM"], + # The style-src directive needs to be specified (even if it's the same as default-src) + # to add the nonce. + "style-src": "'self'", }, - content_security_policy_nonce_in=['script-src'], + content_security_policy_nonce_in=['script-src', 'style-src'], ) + # Template filters + # If there are too many, group them in an extension + app.jinja_env.filters["nickname"] = format_nickname + # Register views import_all("noggin.controller") app.register_blueprint(blueprint) diff --git a/noggin/controller/password.py b/noggin/controller/password.py index c2b96d49f..35895c25d 100644 --- a/noggin/controller/password.py +++ b/noggin/controller/password.py @@ -151,7 +151,7 @@ def forgot_password_ask(): "username", _("User %(username)s does not exist", username=username) ) token = make_token( - {"sub": user.username, "lpc": user.last_password_change}, + {"sub": user.username, "lpc": user.last_password_change.isoformat()}, audience=Audience.password_reset, ) # Send the email @@ -207,7 +207,7 @@ def forgot_password_change(): flash(_("The token has expired, please request a new one."), "warning") return redirect(url_for('.forgot_password_ask')) user = User(ipa_admin.user_show(a_uid=username)['result']) - if user.last_password_change != token_data["lpc"]: + if user.last_password_change.isoformat() != token_data["lpc"]: lock.delete() flash( _( @@ -266,7 +266,7 @@ def forgot_password_change(): # re-generate a token so they can keep going. user = User(ipa_admin.user_show(a_uid=username)['result']) token = make_token( - {"sub": user.username, "lpc": user.last_password_change}, + {"sub": user.username, "lpc": user.last_password_change.isoformat()}, audience=Audience.password_reset, ) form.otp.errors.append(_("Incorrect value.")) diff --git a/noggin/controller/registration.py b/noggin/controller/registration.py index 402c626ff..6edfd1a9b 100644 --- a/noggin/controller/registration.py +++ b/noggin/controller/registration.py @@ -20,11 +20,16 @@ from unidecode import unidecode from noggin.app import csrf, ipa_admin, mailer -from noggin.form.register_user import PasswordSetForm, ResendValidationEmailForm +from noggin.form.register_user import ( + PasswordSetForm, + RegisteringActionForm, + ResendValidationEmailForm, +) from noggin.l10n import guess_locale from noggin.representation.user import User from noggin.security.ipa import maybe_ipa_login, untouched_ipa_client from noggin.signals import stageuser_created, user_registered +from noggin.utility.controllers import with_ipa from noggin.utility.forms import FormError, handle_form_errors from noggin.utility.token import Audience, make_token, read_token @@ -92,7 +97,7 @@ def _handle_registration_validation_error(username, e): def handle_register_form(form): username = form.username.data now = datetime.datetime.utcnow().replace(microsecond=0) - common_name = form.firstname.data + " " + form.lastname.data + common_name = f"{form.firstname.data} {form.lastname.data}" gecos = ( unidecode(codecs.encode(common_name, "translit/long")) .replace(" ", " ") @@ -101,7 +106,7 @@ def handle_register_form(form): # First, create the stage user. try: user = ipa_admin.stageuser_add( - a_uid=username, + username, o_givenname=form.firstname.data, o_sn=form.lastname.data, o_cn=common_name, @@ -116,7 +121,12 @@ def handle_register_form(form): user = User(user) except python_freeipa.exceptions.DuplicateEntry: raise FormError( - "username", _("This username is already taken, please choose another one.") + "non_field_errors", + _( + "The username '%(username)s' or the email address '%(email)s' are already taken.", + username=username, + email=form.mail.data, + ), ) except python_freeipa.exceptions.ValidationError as e: # for example: invalid username. We don't know which field to link it to @@ -354,3 +364,91 @@ def spamcheck_hook(): _send_validation_email(user) return jsonify({"status": "success"}) + + +@bp.route('/registering/', methods=["GET", "POST"]) +@with_ipa() +def registering_users(ipa): + stage_users = ipa.stageuser_find()["result"] + stage_users = [User(su) for su in stage_users] + + statuses = [ + {"name": "", "title": _("All")}, + {"name": "spamcheck_manual", "title": _("Unknown")}, + {"name": "active", "title": _("Not Spam")}, + {"name": "spamcheck_denied", "title": _("Spam")}, + {"name": "spamcheck_awaiting", "title": _("Awaiting")}, + ] + for status in statuses: + status["count"] = len( + [ + su + for su in stage_users + if status["name"] == "" or su.status_note == status["name"] + ] + ) + + status_filter = request.args.get("status", "") + if status_filter: + stage_users = [su for su in stage_users if su.status_note == status_filter] + stage_users.sort(key=lambda u: u.creation_time) + stage_users.reverse() + + form = RegisteringActionForm() + + if form.validate_on_submit(): + username = form.username.data + action = form.action.data + try: + user = [su for su in stage_users if su.username == username][0] + except IndexError: + flash(f"Unknown user: {username}", "danger") + return redirect(request.url) + + if action == "accept": + try: + current_app.logger.info(f"Accepting registering user {username}") + ipa.stageuser_mod(username, fasstatusnote="active") + _send_validation_email(user) + except Exception as e: + form.non_field_errors.errors.append( + f"Could not accept registering user {username}: {e}" + ) + else: + flash(f"Accepted registering user {username}", "success") + return redirect(request.url) + + elif action == "spam": + try: + current_app.logger.info(f"Flagging registering user {username} as spam") + ipa.stageuser_mod(username, fasstatusnote="spamcheck_denied") + except Exception as e: + form.non_field_errors.errors.append( + f"Could not flag registering user {username} as spam: {e}" + ) + else: + flash(f"Flagged registering user {username} as spam", "success") + return redirect(request.url) + + elif action == "delete": + try: + current_app.logger.info(f"Deleting registering user {username}") + ipa.stageuser_del(username) + except Exception as e: + form.non_field_errors.errors.append( + f"Could not delete registering user {username}: {e}" + ) + else: + flash(f"Deleted registering user {username}", "success") + return redirect(request.url) + + else: + form.non_field_errors.errors.append(f"Invalid action: {action}") + + return render_template( + "registering.html", + statuses=statuses, + stage_users=stage_users, + form=form, + filter=status_filter, + ) diff --git a/noggin/controller/user.py b/noggin/controller/user.py index f3e978b70..d395114d5 100644 --- a/noggin/controller/user.py +++ b/noggin/controller/user.py @@ -1,6 +1,7 @@ import os from base64 import b32encode +import jwt import python_freeipa from flask import ( current_app, @@ -9,17 +10,22 @@ Markup, redirect, render_template, + request, session, url_for, ) from flask_babel import _ +from flask_mail import Message from pyotp import TOTP from werkzeug.datastructures import MultiDict +from noggin.app import mailer +from noggin.form.base import BaseForm from noggin.form.edit_user import ( UserSettingsAddOTPForm, UserSettingsAgreementSign, UserSettingsConfirmOTPForm, + UserSettingsEmailForm, UserSettingsKeysForm, UserSettingsOTPStatusChange, UserSettingsProfileForm, @@ -32,6 +38,7 @@ from noggin.utility import messaging from noggin.utility.controllers import require_self, user_or_404, with_ipa from noggin.utility.forms import FormError, handle_form_errors +from noggin.utility.token import Audience, make_token, read_token from noggin_messages import UserUpdateV1 from . import blueprint as bp @@ -129,33 +136,151 @@ def user_settings_profile(ipa, username): form = UserSettingsProfileForm(obj=user) if form.validate_on_submit(): + changes = { + user.get_attr_option(field.short_name): getattr(form, field.short_name).data + for field in form + if field.short_name in user + } + fullname = f"{form.firstname.data} {form.lastname.data}" + changes["o_cn"] = changes["o_displayname"] = fullname + result = _user_mod(ipa, form, user, changes, ".user_settings_profile",) + if result: + return result + if not form.errors: + form.ircnick.append_entry() + + return render_template( + 'user-settings-profile.html', user=user, form=form, activetab="profile" + ) + + +def _send_validation_email(user, attr, value): + token = make_token( + {"sub": user.username, "attr": attr, "mail": value}, + audience=Audience.email_validation, + ttl=current_app.config["ACTIVATION_TOKEN_EXPIRATION"], + ) + email_context = {"token": token, "user": user, "address": value} + email = Message( + body=render_template("settings-email-validation.txt", **email_context), + html=render_template("settings-email-validation.html", **email_context), + recipients=[value], + subject=_("Verify your email address"), + ) + if current_app.config["DEBUG"]: # pragma: no cover + current_app.logger.debug(email) + mailer.send(email) + + +@bp.route('/user//settings/email/', methods=['GET', 'POST']) +@with_ipa() +@require_self +def user_settings_email(ipa, username): + user = User(user_or_404(ipa, username)) + form = UserSettingsEmailForm(obj=user) + attrs = ["mail", "rhbz_mail"] + + if form.validate_on_submit(): + change_now = {} + needs_validation = {} + for attr in attrs: + value = getattr(form, attr).data + old_value = getattr(user, attr) or "" + option_name = user.get_attr_option(attr) + if value != old_value: + if not value: + # email has been removed + change_now[option_name] = value + else: + needs_validation[attr] = value + should_redirect = False + if change_now: + should_redirect = _user_mod( + ipa, form, user, change_now, ".user_settings_email", + ) + if needs_validation: + for attr, value in needs_validation.items(): + try: + _send_validation_email(user, attr, value) + except ConnectionRefusedError as e: + current_app.logger.error( + f"Impossible to send an address validation email: {e}" + ) + form["non_field_errors"].errors.append( + _( + "We could not send you the address validation email, please retry later" + ) + ) + break + flash( + _( + "The email address %(mail)s needs to be validated. Please check your " + "inbox and click on the link to proceed. If you can't find the email " + "in a couple minutes, check your spam folder.", + mail=value, + ), + "info", + ) + should_redirect = redirect( + url_for('.user_settings_email', username=user.username) + ) + if should_redirect: + return should_redirect + if not change_now and not needs_validation: + form["non_field_errors"].errors.append(_("No modifications.")) + + return render_template( + 'user-settings-email.html', user=user, form=form, activetab="email" + ) + + +@bp.route('/user//settings/email/validate', methods=['GET', 'POST']) +@with_ipa() +@require_self +def user_settings_email_validate(ipa, username): + user = User(user_or_404(ipa, username)) + + url = url_for('.user_settings_email', username=user.username) + token_string = request.args.get('token') + + if not token_string: + flash( + _('No token provided, please check your email validation link.'), 'warning' + ) + return redirect(url) + + try: + token = read_token(token_string, audience=Audience.email_validation) + except jwt.exceptions.DecodeError: + flash(_("The token is invalid, please set the email again."), "warning") + return redirect(url) + except jwt.exceptions.ExpiredSignatureError: + flash( + _("This token is no longer valid, please set the email again."), "warning" + ) + return redirect(url) + if token["sub"] != user.username: + flash(_("This token does not belong to you."), "warning") + return redirect(url) + + attr = token["attr"] + value = token["mail"] + form = BaseForm() + + if form.validate_on_submit(): + option_name = user.get_attr_option(token["attr"]) result = _user_mod( - ipa, - form, - user, - { - 'o_givenname': form.firstname.data, - 'o_sn': form.lastname.data, - 'o_cn': '%s %s' % (form.firstname.data, form.lastname.data), - 'o_displayname': '%s %s' % (form.firstname.data, form.lastname.data), - 'o_mail': form.mail.data, - 'fasircnick': form.ircnick.data, - 'faslocale': form.locale.data, - 'fastimezone': form.timezone.data, - 'fasgithubusername': form.github.data.lstrip('@'), - 'fasgitlabusername': form.gitlab.data.lstrip('@'), - 'fasrhbzemail': form.rhbz_mail.data, - 'faswebsiteurl': form.website_url.data, - 'fasisprivate': form.is_private.data, - 'faspronoun': form.pronouns.data, - }, - ".user_settings_profile", + ipa, form, user, {option_name: value}, ".user_settings_email", ) if result: return result return render_template( - 'user-settings-profile.html', user=user, form=form, activetab="profile" + 'user-settings-email-validation.html', + form=form, + user=user, + attr_label=UserSettingsEmailForm()[attr].label, + value=value, ) diff --git a/noggin/defaults.py b/noggin/defaults.py index 873a623e7..17d3065b7 100644 --- a/noggin/defaults.py +++ b/noggin/defaults.py @@ -29,6 +29,16 @@ PAGE_SIZE = 30 +CHAT_NETWORKS = { + "irc": {"default_server": "irc.libera.chat"}, + "matrix": {"default_server": "matrix.org"}, +} + +STAGE_USERS_ROLE = "Stage User Managers" + +TEMPLATES_CUSTOM_DIRECTORIES = [] +ACCEPT_IMAGES_FROM = [] + BASSET_URL = None SPAMCHECK_TOKEN_EXPIRATION = 60 # in minutes diff --git a/noggin/form/base.py b/noggin/form/base.py index 2cb3836eb..cdb327d08 100644 --- a/noggin/form/base.py +++ b/noggin/form/base.py @@ -1,6 +1,8 @@ from flask_wtf import FlaskForm from markupsafe import escape, Markup from wtforms import Field, SubmitField +from wtforms.fields.core import FieldList, SelectField, StringField +from wtforms.utils import unset_value from wtforms.widgets import TextInput from wtforms.widgets.core import html_params @@ -70,10 +72,21 @@ def strip(value): return value.strip() if value else value +def strip_at(value): + return value.lstrip("@") if value else value + + def lower(value): return value.lower() if value else value +def replace(target, replacement): + def _replace(value): + return value.replace(target, replacement) if value else value + + return _replace + + class CSVListField(Field): widget = TextInput() @@ -88,3 +101,89 @@ def process_formdata(self, values): self.data = [x.strip() for x in values[0].split(',') if x.strip()] else: self.data = [] + + +class TypeAndStringWidget(TextInput): + def __call__(self, field, **kwargs): + kwargs.setdefault('id', field.id) + errors = [str(e) for e in field.errors or []] + if errors: + errors.insert(0, '
') + errors.append('
') + html = [ + '
', + '
', + field.subfields[0](class_="custom-select"), + '
', + field.subfields[1](**kwargs), + '
', + '', + '
', + '
', + " ".join(errors), + ] + return Markup(''.join(html)) + + +class TypeAndStringField(Field): + widget = TypeAndStringWidget() + + def __init__(self, *args, **kwargs): + choices = kwargs.pop("choices", None) + super().__init__(*args, **kwargs) + self._prefix = kwargs.get('_prefix', '') + self.subfields = [] + self._add_field("type", SelectField(choices=choices)) + self._add_field( + "value", + StringField( + filters=kwargs.get("filters"), validators=kwargs.get("validators") + ), + ) + + def _parse_data(self, data): + raise NotImplementedError # pragma: no cover + + def _serialize_data(self, scheme, value): + raise NotImplementedError # pragma: no cover + + def _add_field(self, name, unbound_field): + self.subfields.append( + unbound_field.bind( + form=None, + name=f"{self.short_name}-{name}", + prefix=self._prefix, + id=f"{self.id}-{name}", + _meta=self.meta, + translations=self._translations, + ) + ) + + def process(self, formdata, data=unset_value): + if data: + data = self._parse_data(data) + else: + data = (unset_value, unset_value) + for field, field_data in zip(self.subfields, data): + field.process(formdata, field_data) + + @property + def data(self): + scheme = self.subfields[0].data + value = self.subfields[1].data + if not value: + return "" + return self._serialize_data(scheme, value) + + +class NonEmptyFieldList(FieldList): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.type = "FieldList" + + @property + def data(self): + return [f.data.strip() for f in self.entries if f.data and f.data.strip()] diff --git a/noggin/form/edit_user.py b/noggin/form/edit_user.py index 2031336ed..14b9f1a66 100644 --- a/noggin/form/edit_user.py +++ b/noggin/form/edit_user.py @@ -1,3 +1,6 @@ +import re +from urllib.parse import urlparse, urlunparse + from flask_babel import lazy_gettext as _ from pyotp import TOTP from wtforms import ( @@ -23,7 +26,56 @@ from noggin.l10n import LOCALES from noggin.utility.timezones import TIMEZONES -from .base import BaseForm, CSVListField, ModestForm, strip, SubmitButtonField +from .base import ( + BaseForm, + CSVListField, + ModestForm, + NonEmptyFieldList, + replace, + strip, + strip_at, + SubmitButtonField, + TypeAndStringField, +) + + +NICK_RE = { + "irc": re.compile(r"^[a-z_\[\]\\^{}|`-][a-z0-9_\[\]\\^{}|`-]*$", re.IGNORECASE), + "matrix": re.compile(r"^[a-z0-9.=_/-]+$", re.IGNORECASE), +} +SERVER_RE = re.compile(r"^[a-z0-9][a-z0-9.-]*(:[0-9]+)?$", re.IGNORECASE) + + +class ProtocolAndNickField(TypeAndStringField): + def __init__(self, *args, **kwargs): + kwargs["choices"] = [("irc", "IRC"), ("matrix", "Matrix")] + kwargs["filters"] = [replace(" ", ""), strip_at, replace("@", ":")] + kwargs["validators"] = [self._validate] + super().__init__(*args, **kwargs) + + def _parse_data(self, data): + url = urlparse(data) + nick = url.path.lstrip("/") + if url.netloc: + nick = f"{nick}:{url.netloc}" + scheme = url.scheme or "irc" + return (scheme, nick) + + def _serialize_data(self, scheme, value): + nick, sep_, server = value.partition(":") + return urlunparse((scheme, server.strip(), f"/{nick.strip()}", "", "", "")) + + @staticmethod + def _validate(form, field): + scheme = field.subfields[0].data + value = field.subfields[1].data + if not value: + return + nick, sep_, server = value.partition(":") + if not NICK_RE[scheme].match(nick): + raise ValidationError(_("This does not look like a valid nickname.")) + if server and not SERVER_RE.match(server): + raise ValidationError(_("This does not look like a valid server name.")) class UserSettingsProfileForm(BaseForm): @@ -37,14 +89,6 @@ class UserSettingsProfileForm(BaseForm): validators=[DataRequired(message=_('Last name must not be empty'))], ) - mail = EmailField( - _('E-mail Address'), - validators=[ - DataRequired(message=_('Email must not be empty')), - Email(message=_('Email must be valid')), - ], - ) - locale = SelectField( _('Locale'), choices=[(locale, locale) for locale in LOCALES], @@ -54,7 +98,10 @@ class UserSettingsProfileForm(BaseForm): ], ) - ircnick = CSVListField(_('IRC Nicknames'), validators=[Optional()]) + ircnick = NonEmptyFieldList( + ProtocolAndNickField(validators=[Optional()]), + label=_('Chat Nicknames'), + ) timezone = SelectField( _('Timezone'), @@ -65,11 +112,13 @@ class UserSettingsProfileForm(BaseForm): ], ) - github = StringField(_('GitHub Username'), validators=[Optional()]) - - gitlab = StringField(_('GitLab Username'), validators=[Optional()]) + github = StringField( + _('GitHub Username'), validators=[Optional()], filters=[strip_at] + ) - rhbz_mail = EmailField(_('Red Hat Bugzilla Email'), validators=[Optional()]) + gitlab = StringField( + _('GitLab Username'), validators=[Optional()], filters=[strip_at] + ) website_url = URLField( _('Website or Blog URL'), @@ -84,7 +133,19 @@ class UserSettingsProfileForm(BaseForm): validators=[Optional()], ) - pronouns = StringField(_('Pronouns'), validators=[Optional()],) + pronouns = CSVListField(_('Pronouns'), validators=[Optional()]) + + +class UserSettingsEmailForm(BaseForm): + mail = EmailField( + _('E-mail Address'), + validators=[ + DataRequired(message=_('Email must not be empty')), + Email(message=_('Email must be valid')), + ], + ) + + rhbz_mail = EmailField(_('Red Hat Bugzilla Email'), validators=[Optional()]) class UserSettingsKeysForm(BaseForm): diff --git a/noggin/form/register_user.py b/noggin/form/register_user.py index 8e3b7fb77..4c0df99f0 100644 --- a/noggin/form/register_user.py +++ b/noggin/form/register_user.py @@ -1,5 +1,5 @@ from flask_babel import lazy_gettext as _ -from wtforms import BooleanField, PasswordField, StringField +from wtforms import BooleanField, HiddenField, PasswordField, StringField from wtforms.fields.html5 import EmailField from wtforms.validators import DataRequired, EqualTo @@ -71,3 +71,8 @@ class PasswordSetForm(BaseForm): password_confirm = PasswordField(_('Confirm Password'), filters=[strip]) submit = SubmitButtonField(_("Activate")) + + +class RegisteringActionForm(BaseForm): + username = HiddenField(validators=[DataRequired()]) + action = StringField(validators=[DataRequired()]) diff --git a/noggin/representation/base.py b/noggin/representation/base.py index 028ec0a00..bed659486 100644 --- a/noggin/representation/base.py +++ b/noggin/representation/base.py @@ -1,3 +1,6 @@ +from datetime import datetime + + def attr_to_str(value): if not value: return None @@ -14,10 +17,18 @@ def attr_to_bool(value): return value[0] == "TRUE" +def attr_to_date(value): + if value is None: + return None + dt = value[0]["__datetime__"] + return datetime.strptime(dt, r"%Y%m%d%H%M%SZ") + + CONVERTERS = { "str": attr_to_str, "list": attr_to_list, "bool": attr_to_bool, + "date": attr_to_date, } @@ -25,6 +36,7 @@ class Representation: attr_names = {} attr_types = {} + attr_options = {} pkey = None def __init__(self, raw): @@ -71,8 +83,12 @@ def diff_fields(self, other): f"Can't diff a {self.__class__.__name__} instance against a " f"{other.__class__.__name__} instance" ) - return [key for key in self if getattr(self, key) != getattr(other, key)] def as_dict(self): return {attr: getattr(self, attr) for attr in self.attr_names} + + def get_attr_option(self, attr): + attr_to_options = self.attr_names.copy() + attr_to_options.update(self.attr_options) + return attr_to_options[attr] diff --git a/noggin/representation/user.py b/noggin/representation/user.py index de0bf997d..3ab05bdce 100644 --- a/noggin/representation/user.py +++ b/noggin/representation/user.py @@ -27,6 +27,7 @@ class User(Representation): "is_private": "fasisprivate", "pronouns": "faspronoun", "krbname": "krbcanonicalname", + "roles": "memberof_role", } attr_types = { "sshpubkeys": "list", @@ -35,6 +36,15 @@ class User(Representation): "groups": "list", "agreements": "list", "is_private": "bool", + "pronouns": "list", + "creation_time": "date", + "last_password_change": "date", + "roles": "list", + } + attr_options = { + "firstname": "o_givenname", + "lastname": "o_sn", + "mail": "o_mail", } pkey = "username" ipa_object = "user" diff --git a/noggin/security/ipa_admin.py b/noggin/security/ipa_admin.py index 4b7bc2c9e..6868b9191 100644 --- a/noggin/security/ipa_admin.py +++ b/noggin/security/ipa_admin.py @@ -41,6 +41,10 @@ class IPAAdmin: "fasagreement_remove_group", "fasagreement_remove_user", "fasagreement_disable", + "role_add", + "role_del", + "role_add_privilege", + "role_add_member", ) def __init__(self, app=None): diff --git a/noggin/static/css/main.css b/noggin/static/css/main.css index f6354bc8f..b53f0a429 100644 --- a/noggin/static/css/main.css +++ b/noggin/static/css/main.css @@ -2,39 +2,43 @@ https://github.com/twhetzel/flask-jquery-autocomplete-demo/blob/b8b07a59a120e3254483848707da993463c56e91/templates/materialTagsExamples.html */ .tt-hint { - color: #999; - left: inherit !important; + color: #999; + left: inherit !important; } .tt-menu { - width: 100%; - padding: 8px 0; - background-color: #fff; - border: 1px solid #ccc; - border: 1px solid rgba(0, 0, 0, 0.2); - box-shadow: 0 5px 10px rgba(0,0,0,.2); - color: #000; + width: 100%; + padding: 8px 0; + background-color: #fff; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, 0.2); + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + color: #000; } -.tt-menu h5 { padding: 5px; } +.tt-menu h5 { + padding: 5px; +} .tt-suggestion { - padding: 3px 20px; - font-size: 18px; - line-height: 24px; + padding: 3px 20px; + font-size: 18px; + line-height: 24px; } -.tt-suggestion p { margin: 0; } +.tt-suggestion p { + margin: 0; +} .tt-suggestion > p:hover, .tt-suggestion > p:focus { - color: #fff; - text-decoration: none; - outline: 0; - background-color: #428bca; + color: #fff; + text-decoration: none; + outline: 0; + background-color: #428bca; } .tt-suggestion.tt-cursor { - color: #fff; - background-color: #428bca; + color: #fff; + background-color: #428bca; } .tt-suggestion.tt-cursor, .tt-suggestion:hover { @@ -43,13 +47,15 @@ cursor: pointer; } -.modal.with-typeahead { overflow-y: visible; } +.modal.with-typeahead { + overflow-y: visible; +} .modal.with-typeahead .tt-menu { - top: 50% !important; + top: 50% !important; } .twitter-typeahead { - width: 100%; + width: 100%; } /* Moves the footer to the bottom of the page */ @@ -62,3 +68,20 @@ flex-flow: column; } +/* Labels in forms are weighted */ +.form-group label { + font-weight: bolder; +} + +/* Support select tags as prepended fields */ +.input-group > .input-group-prepend > select.form-control, +.input-group > .input-group-prepend > select.custom-select { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +/* Registering users */ + +.fasstatus .badge { + font-size: 90%; +} diff --git a/noggin/templates/_form_macros.html b/noggin/templates/_form_macros.html index eb4ef87a8..d8f655ff6 100644 --- a/noggin/templates/_form_macros.html +++ b/noggin/templates/_form_macros.html @@ -9,13 +9,11 @@ {% set css_class = css_class + ' is-invalid ' %} {% endif %} {% if kwargs.pop('label', True) and field.type != "BooleanField" %} - {{ field.label }} - {% endif %} {{ field(class=css_class, **kwargs) }} {% if field.type == 'BooleanField' %} - + {% endif %} {% if field.description %} {{ field.description }} @@ -35,6 +33,9 @@ {% for subfield in field %} {{ form_field(subfield, label=False, **kwargs) }} {% endfor %} + {% if field.description %} + {{ field.description }} + {% endif %} {% else %} {{ form_field(field, **kwargs) }} {% endif %} diff --git a/noggin/templates/master.html b/noggin/templates/base.html similarity index 92% rename from noggin/templates/master.html rename to noggin/templates/base.html index b3969e054..38b46945f 100644 --- a/noggin/templates/master.html +++ b/noggin/templates/base.html @@ -8,10 +8,13 @@ {% block title %}{% endblock %}{% if self.title() %} - {% endif %}{% block website %}noggin{% endblock %} + {% include "head.html" ignore missing %} {% block navbar %} {% endblock %} + {% include "after-navbar.html" ignore missing %} {% block bodycontent %}{% endblock %} + {% include "before-footer.html" ignore missing %} {% block footer %}{% endblock %} {% block scripts %} {% if current_user %} diff --git a/noggin/templates/registering.html b/noggin/templates/registering.html new file mode 100644 index 000000000..e9b78f9ab --- /dev/null +++ b/noggin/templates/registering.html @@ -0,0 +1,134 @@ +{% extends "main.html" %} +{% block title %}{{_("Registering Users")}}{% endblock %} + +{% block content %} + {{ super() }} + + +
+ +

{{_("Registering Users")}}

+ + + + +
+ + {% if stage_users %} + + + +
+ {% for user in stage_users %} +
+ +

{{ user.username }}

+ +
+
{{_("Name:")}}
+
{{ user.name }}
+
+ +
+
{{_("Email:")}}
+
{{ user.mail }}
+
+ +
+
{{_("Registered:")}}
+
{{ user.creation_time }}
+
+ +
+
+ {{_("Status:")}} +
+
+ {% if user.status_note == "spamcheck_awaiting" %} + {{_("Waiting for spam check")}} + {% elif user.status_note == "active" %} + {{_("Not flagged as spam")}} + {% elif user.status_note == "spamcheck_denied" %} + {{_("Flagged as spam")}} + {% elif user.status_note == "spamcheck_manual" %} + {{_("Spam status unknown")}} + {% else %} + {{ user.status_note }} + {% endif %} +
+
+ +
+ {{ form.csrf_token }} + {{ form.username(value=user.username) }} +
+
+ +
+
+ +
+
+ +
+
+ {% if form.errors %} +
+ {% for fieldname, errors in form.errors.items() %} + {% for error in errors %} +
+ {% if fieldname != "non_field_errors" %} + {{fieldname}}: + {% endif %} + {{ error|e }} +
+ {% endfor %} + {% endfor %} +
+ {% endif %} + +

+ {{ _("Clicking on Accept will send the validation email to this user. Other buttons will not send anything.") }} +

+ +
+ +
+ {% endfor %} +
+ + {% else %} + +
+ {% if filter %} +
{{_("No registering users in this state at the moment.")}}
+ {% else %} +
{{_("No registering users at the moment.")}}
+ {% endif %} +
+ + {% endif %} + +
+ +
+ + +{% endblock %} diff --git a/noggin/templates/user-settings-email-validation.html b/noggin/templates/user-settings-email-validation.html new file mode 100644 index 000000000..37ee7f971 --- /dev/null +++ b/noggin/templates/user-settings-email-validation.html @@ -0,0 +1,33 @@ +{% extends "main.html" %} +{% block title %}{{_("Validate your email")}}{% endblock %} + +{% block content %} + {{ super() }} + + +{% import '_form_macros.html' as macros %} + +
+
+
+
+
+ {{ form.csrf_token }} +
+

{{_("Validate your email")}}

+

{{_("Hello %(user_name)s. Do you want to set your %(attr_name)s to %(mail)s?", user_name=user.name, attr_name=attr_label, mail=value)}}

+
+ +
+
+
+
+
+ +{% endblock %} diff --git a/noggin/templates/user-settings-email.html b/noggin/templates/user-settings-email.html new file mode 100644 index 000000000..6121c4359 --- /dev/null +++ b/noggin/templates/user-settings-email.html @@ -0,0 +1,22 @@ +{% extends "user-settings.html" %} + +{% import '_form_macros.html' as macros %} + +{% block settings_content %} +
+ {{ form.csrf_token }} +
+
{{ macros.with_errors(form.mail) }}
+
{{ macros.with_errors(form.rhbz_mail) }}
+
+ +
+{% endblock %} + +{% block scripts %} + {{ super () }} + {{ macros.unsaved_changes() }} +{% endblock %} diff --git a/noggin/templates/user-settings-profile.html b/noggin/templates/user-settings-profile.html index 5862610ea..e0d30d360 100644 --- a/noggin/templates/user-settings-profile.html +++ b/noggin/templates/user-settings-profile.html @@ -29,17 +29,32 @@
{{ macros.with_errors(form.pronouns) }}
{{ macros.with_errors(form.locale, class="custom-select") }}
{{ macros.with_errors(form.timezone, class="custom-select") }}
-
{{ macros.with_errors(form.mail) }}
{{ macros.with_errors(form.website_url) }}
-
{{ macros.with_errors(form.ircnick) }}
+
+

{{ form.ircnick.label(class_="mt-2") }}

+

+ {% trans %} + The format is either username or username:server.name if you're not using the default servers: + {% endtrans %} +

+
    + {% for name, title in form.ircnick.entries[0].subfields[0].choices %} +
  • + {% trans server=config["CHAT_NETWORKS"][name]["default_server"] %} + For {{title}}: {{server}} + {% endtrans %} +
  • + {% endfor %} +
+ {{ macros.with_errors(form.ircnick, label=False) }} +
{{ macros.with_errors(form.github) }}
{{ macros.with_errors(form.gitlab) }}
-
{{ macros.with_errors(form.rhbz_mail) }}
{{ macros.with_errors(form.is_private) }}
{% endblock %} @@ -49,17 +64,11 @@ {{ macros.unsaved_changes() }} {{ macros.selectize() }}