diff --git a/config/urls.py b/config/urls.py index 82a88b85f..448336582 100644 --- a/config/urls.py +++ b/config/urls.py @@ -83,6 +83,8 @@ CustomLoginView, CustomSignupView, CustomSocialSignupViewView, + V3LoginView, + V3SignupView, UserViewSet, UserAvatar, DeleteUserView, @@ -257,6 +259,16 @@ staff_member_required(LearnPageView.as_view()), name="v3-learn-page", ), + path( + "v3/accounts/signup/", + V3SignupView.as_view(), + name="v3-signup", + ), + path( + "v3/accounts/login/", + V3LoginView.as_view(), + name="v3-login", + ), path("libraries/", LibraryListDispatcher.as_view(), name="libraries"), path( "libraries///", diff --git a/static/css/v3/auth-page.css b/static/css/v3/auth-page.css new file mode 100644 index 000000000..798d6085f --- /dev/null +++ b/static/css/v3/auth-page.css @@ -0,0 +1,213 @@ +/* + Auth Page + Two-column layout for sign-up and sign-in pages. + Left: illustration image. Right: centered form content. + Dependencies: foundations, forms, buttons. +*/ + +/* ── Header overlay on auth pages ─────────── */ +body:has(.auth-page) .header { + position: absolute; + z-index: 10; + left: 0; + right: 0; +} + +/* ── Page-level flex column ───────────────── */ +.auth-page { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ── Reset base.html intermediate wrappers ──── */ +.auth-page .w-full, +.auth-page .md\:px-0 { + min-height: auto; + padding: 0; + width: 100%; +} + +.auth-page .min-vh-110 { + min-height: auto; + padding: 0; + width: 100%; + flex: 1; + display: flex; + flex-direction: column; +} + +.auth-page p { + margin: 0; + padding: 0; +} + +/* ── Two-column grid layout ───────────────── */ +.auth-page__wrapper { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + max-width: 1440px; + flex: 1; + min-height: 0; + overflow: hidden; + margin: 0 auto; +} + +/* ── Illustration Panel ────────────────────── */ +.auth-page__illustration { + position: relative; + overflow: hidden; +} + +.auth-page__illustration-background { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 0; +} + +.auth-page__illustration-foreground { + margin-top: 80px; /* This is the size of the header navbar, added so that the content will not be hidden behind it */ + position: absolute; + inset: 0; + width: 100%; + object-fit: contain; + z-index: 1; + align-self: center; +} + +/* ── Content Panel ─────────────────────────── */ +.auth-page__content { + display: flex; + justify-content: center; + align-items: center; + padding: var(--space-xlarge); + margin-top: 80px; /* This is the size of the header navbar, added so that the content will not be hidden behind it */ +} + +.auth-page__content-inner { + max-width: 458px; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--space-large); +} + +/* ── Header ────────────────────────────────── */ +.auth-page__header { + display: flex; + flex-direction: column; + gap: var(--space-medium); +} + +.auth-page__title { + font-family: var(--font-display); + font-size: var(--font-size-large); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-tight); + letter-spacing: -0.01em; + color: var(--color-text-primary); + margin: 0; +} + +.auth-page__subtitle { + font-family: var(--font-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-default); + letter-spacing: -0.01em; + color: var(--color-text-secondary); + margin: 0; +} + +/* ── Card ──────────────────────────────────── */ +.auth-page__card { + display: flex; + flex-direction: column; + gap: var(--space-large); + padding: var(--space-large); + border: 1px solid var(--color-stroke-weak); + border-radius: var(--border-radius-xxl); + background: var(--color-surface-weak); +} + +/* ── Forgot Password Link ──────────────────── */ +.auth-page__forgot-link { + font-family: var(--font-sans); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-regular); + line-height: 120%; + letter-spacing: -0.12px; + color: var(--color-text-primary); + text-decoration: underline; + text-decoration-skip-ink: auto; + text-underline-offset: auto; + width: fit-content; +} + +.auth-page__forgot-link:hover { + color: var(--color-text-link-accent); +} + +/* ── Divider ───────────────────────────────── */ +.auth-page__divider { + font-family: var(--font-display); + font-size: 18px; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-tight); + letter-spacing: -0.01em; + color: var(--color-text-primary); + text-align: center; + margin: 0; +} + +/* ── Social Card ───────────────────────────── */ +.auth-page__social-card { + display: flex; + flex-direction: column; + gap: var(--space-default); + padding: var(--space-large); + border: 1px solid var(--color-stroke-weak); + border-radius: var(--border-radius-xxl); + background: var(--color-surface-weak); +} + +/* ── Responsive (Tablet) ────────────────────────────── */ +@media (max-width: 1279px) { + .auth-page { + min-height: auto; + } + + .auth-page__wrapper { + grid-template-columns: 1fr; + flex: none; + } + + .auth-page__illustration { + height: 592px; + } + + .auth-page__illustration-foreground { + height: 500px; + } + + .auth-page__content { + padding: var(--space-medium); + align-items: flex-start; + margin-top: 0; + } +} + +/* ── Responsive (Mobile) ────────────────────────────── */ +@media (max-width: 767px) { + .auth-page__illustration { + height: 368px; + } + + .auth-page__illustration-foreground { + height: 280px; + } +} diff --git a/static/css/v3/buttons.css b/static/css/v3/buttons.css index ea73c3ef4..fa6d19ed5 100644 --- a/static/css/v3/buttons.css +++ b/static/css/v3/buttons.css @@ -34,6 +34,12 @@ color 0.15s ease; } +.btn:disabled { + opacity: 0.5; + pointer-events: none; + cursor: default; +} + .btn-row { display: flex; flex-wrap: wrap; diff --git a/static/css/v3/forms.css b/static/css/v3/forms.css index 64576bce5..4fe42fa4a 100644 --- a/static/css/v3/forms.css +++ b/static/css/v3/forms.css @@ -597,6 +597,7 @@ font-size: var(--font-size-small, 14px); font-weight: var(--font-weight-regular, 400); line-height: var(--line-height-relaxed, 1.24); + letter-spacing: var(--letter-spacing-tight); color: var(--color-text-primary, #050816); } @@ -723,3 +724,81 @@ background-color: var(--color-surface-error-weak, #fdf2f2); border-color: var(--color-stroke-error, #d53f3f33); } + +/* ── Password Toggle ───────────────────────── */ +.field__toggle { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + color: var(--color-icon-secondary); + flex-shrink: 0; +} + +.field__toggle:hover { + color: var(--color-icon-primary); +} + +.field__toggle .icon { + width: 16px; + height: 16px; +} + +/* ── Password Checklist ────────────────────── */ +.field__checklist { + display: flex; + flex-direction: column; + gap: var(--space-default); +} + +.field__checklist-heading { + font-family: var(--font-sans); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-default); + letter-spacing: -0.01em; + color: var(--color-text-tertiary); + margin: 0; +} + +.field__checklist-list { + display: flex; + flex-direction: column; + gap: var(--space-s); + list-style: none; + margin: 0; + padding: 0; + flex-shrink: 0; +} + +.field__checklist-item { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--space-s); + font-family: var(--font-sans); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-default); + letter-spacing: -0.01em; + color: var(--color-text-primary); +} + +.field__checklist-item .icon { + width: 16px; + min-width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--color-text-tertiary); +} + +.field__checklist-item--pass .icon { + color: var(--color-syntax-green); +} + +.field__checklist-item--fail .icon { + color: var(--color-text-error); +} diff --git a/static/img/v3/auth-page/auth-page-background.png b/static/img/v3/auth-page/auth-page-background.png new file mode 100644 index 000000000..8867cc64b Binary files /dev/null and b/static/img/v3/auth-page/auth-page-background.png differ diff --git a/static/img/v3/auth-page/auth-page-foreground.png b/static/img/v3/auth-page/auth-page-foreground.png new file mode 100644 index 000000000..4db206ce0 Binary files /dev/null and b/static/img/v3/auth-page/auth-page-foreground.png differ diff --git a/templates/includes/icon.html b/templates/includes/icon.html index 38b2254da..067a5d707 100644 --- a/templates/includes/icon.html +++ b/templates/includes/icon.html @@ -20,6 +20,13 @@ +{% elif icon_name == "google-colored" %} + + + + + + {% elif icon_name == "alert" %}
{% endblock %} + +{% block content_header %}{% endblock %} + +{% block messages %}{% endblock %} + +{% block content %} +
+
+ + +
+
+
+ {% block auth_content %}{% endblock %} +
+
+
+{% endblock %} diff --git a/templates/v3/accounts/login.html b/templates/v3/accounts/login.html new file mode 100644 index 000000000..9371b0860 --- /dev/null +++ b/templates/v3/accounts/login.html @@ -0,0 +1,77 @@ +{% extends "v3/accounts/auth_page.html" %} +{% comment %} + Login (Sign In) Page — extends the auth page layout. + Renders the login form with email, password, forgot password link, + and social login options. + + Context variables: + page_title (string, optional, default "Account") — passed to auth_page.html + foreground_image_url (string, optional, default "") — passed to auth_page.html + background_image_url (string, optional, default "") — passed to auth_page.html + + signup_url (string, required) — URL for the sign-up page + password_reset_url (string, required) — URL for the password reset page + form (LoginForm, required) — allauth login form with .login, .password fields and .errors + redirect_field_name (string, optional, default unset) — name of the redirect query parameter; hidden input omitted when redirect_field_value is unset + redirect_field_value (string, optional, default unset) — URL to redirect to after login; hidden input omitted when unset + contributor_account_redirect_message (string, optional, default unset) — message for pre-created author/maintainer accounts; alert omitted when unset +{% endcomment %} +{% load static %} + +{% block auth_content %} +
+

Login to your account

+

+ Sign in to continue your journey with the Boost and C++ community. +

+
+ +
+ {% csrf_token %} + {% include "v3/includes/_field_text.html" with name="login" type="email" label="Email Address*" placeholder="Email Address" required=True validate_type="email" validate_error="That doesn't look quite right, please double-check your email and try again." error=form.login.errors.0 %} + {% include "v3/includes/_field_password.html" with name="password" label="Password*" placeholder="Password" required=True error=form.password.errors.0 %} + {% if form.non_field_errors %} + {% for error in form.non_field_errors %} + + {% endfor %} + {% endif %} + {% if contributor_account_redirect_message %} + + {% endif %} + {% include "v3/includes/_field_checkbox.html" with name="remember" label="Remember me" %} + Forgot password + {% if redirect_field_value %} + + {% endif %} + {% include "v3/includes/_button.html" with label="Sign in" type="submit" style="primary" alpine_disabled="hasErrors" %} +
+ +

OR

+ + +{% endblock auth_content %} diff --git a/templates/v3/accounts/signup.html b/templates/v3/accounts/signup.html new file mode 100644 index 000000000..b174dd783 --- /dev/null +++ b/templates/v3/accounts/signup.html @@ -0,0 +1,69 @@ +{% extends "v3/accounts/auth_page.html" %} +{% comment %} + Sign Up (Create Account) Page — extends the auth page layout. + Renders the account creation form with email, password fields, + mailing list checkbox, and social login options. Inherits from + allauth's SignupView for full registration handling. + + This form triggers client-side validation on all fields, then submits natively if no errors. + + Context variables: + page_title (string, optional, default "Account") — passed to auth_page.html + foreground_image_url (string, optional, default "") — passed to auth_page.html + background_image_url (string, optional, default "") — passed to auth_page.html + + login_url (string, required) — URL for the login page + form (SignupForm, required) — allauth signup form with .email, .password1, .password2 fields and .errors + redirect_field_name (string, optional, default unset) — name of the redirect query parameter; hidden input omitted when redirect_field_value is unset + redirect_field_value (string, optional, default unset) — URL to redirect to after signup; hidden input omitted when unset + password_rules (list, optional, default unset) — rule objects for password validation checklist +{% endcomment %} +{% load static %} +{% block auth_content %} +
+

Create an account

+

Advance your career, learn from experts, and help shape the future of Boost and C++.

+
+
+ {% csrf_token %} + {% include "v3/includes/_field_text.html" with name="email" type="email" label="Email Address*" placeholder="Email Address" required=True validate_type="email" validate_error="That doesn't look quite right, please double-check your email and try again." error=form.email.errors.0 %} + {% include "v3/includes/_field_password.html" with name="password1" label="Password*" placeholder="Password" required=True password_rules=password_rules %} + {% include "v3/includes/_field_password.html" with name="password2" label="Re-enter Password*" placeholder="Password" required=True error=form.password2.errors.0 password_rules="" validate_match="#field-password1" validate_match_error="Passwords do not match, please try again." %} + {% include "v3/includes/_field_text.html" with name="username" label="Username" placeholder="Username" %} + {% if form.non_field_errors %} + {% for error in form.non_field_errors %}{% endfor %} + {% endif %} + {% include "v3/includes/_field_checkbox.html" with name="mailing_list" label="Also join the Boost Developers Mailing List" %} + {% if redirect_field_value %} + + {% endif %} + {% include "v3/includes/_button.html" with label="Create Account" type="submit" style="primary" alpine_disabled="hasErrors" %} +
+

OR

+ +{% endblock auth_content %} diff --git a/templates/v3/examples/_v3_example_buttons_block.html b/templates/v3/examples/_v3_example_buttons_block.html index 67fbbf63f..7d7df1eef 100644 --- a/templates/v3/examples/_v3_example_buttons_block.html +++ b/templates/v3/examples/_v3_example_buttons_block.html @@ -17,6 +17,14 @@

Hovered

+

Disabled

+
+ + + + + +

Hero Buttons

diff --git a/templates/v3/includes/_button.html b/templates/v3/includes/_button.html index b825443ac..d5cc36e49 100644 --- a/templates/v3/includes/_button.html +++ b/templates/v3/includes/_button.html @@ -17,6 +17,8 @@ - teal - error - icon-library (uses btn-icon-library styles; ignores btn + btn-* classes) + - disabled (optional): if truthy, adds disabled attribute to button + - alpine_disabled (optional): Alpine.js expression for dynamic disabled binding (e.g. "hasErrors") - extra_classes (optional) : Any extra classes that the button should have, as a string - aria_label (optional): An optional label for assistive technology. Defaults to the button label Icon must be wrapped in .... @@ -52,7 +54,9 @@ +
+ + + {% if validate_match %} + + {% endif %} + {% if error %}{% endif %} + {% if password_rules %} + {% with pw_rules_id="pw-rules-"|add:name %}{{ password_rules|json_script:pw_rules_id }}{% endwith %} +
+

Password must include:

+
    + {% for rule in password_rules %} + {% with i=forloop.counter0 %} +
  • + + {% include "includes/icon.html" with icon_name="check" icon_size=16 %} + + + {% include "includes/icon.html" with icon_name="close" icon_size=16 %} + + {{ rule.label }} +
  • + {% endwith %} + {% endfor %} +
+
+ {% endif %} + diff --git a/templates/v3/includes/_field_text.html b/templates/v3/includes/_field_text.html index be88d01af..f1f212920 100644 --- a/templates/v3/includes/_field_text.html +++ b/templates/v3/includes/_field_text.html @@ -1,64 +1,117 @@ {% comment %} V3 text input field. Variables: - name (required) — input name attribute - label (optional) — label text - placeholder (optional) — placeholder text - value (optional) — pre-filled value - type (optional) — input type, default "text" - help_text (optional) — help text below the field - error (optional) — error message (activates error state) - alpine_error (optional) — Alpine expression for dynamic error binding (e.g. "errors.title") - icon_left (optional) — if truthy, shows a search icon on the left - submit_icon (optional) — icon name for a submit button in the right slot (e.g. "arrow-right") - submit_label (optional) — aria-label for the submit button, default "Submit" - required (optional) — if truthy, adds required attribute - disabled (optional) — if truthy, adds disabled attribute - extra_class (optional) — additional classes on the wrapper + name (required) — input name attribute + label (optional) — label text + placeholder (optional) — placeholder text + value (optional) — pre-filled value + type (optional) — input type, default "text" + help_text (optional) — help text below the field + error (optional) — static server-side error message, rendered on page load + alpine_error (optional) — Alpine expression that resolves to an error string for dynamic + server-driven errors without a page reload (e.g. "errors.title") + validate_type (optional) — client-side validation before submit, currently supports "email" + validate_error (optional) — error message shown when validate_type check fails + icon_left (optional) — icon name for left slot (e.g. "search") + submit_icon (optional) — icon name for a submit button in the right slot (e.g. "arrow-right") + submit_label (optional) — aria-label for the submit button, default "Submit" + required (optional) — if truthy, adds required attribute + disabled (optional) — if truthy, adds disabled attribute + extra_class (optional) — additional classes on the wrapper + + Error handling: + error is the base layer for server-side validation and can be combined with either + alpine_error or validate_type. Choose one of the latter per field, not both. + Usage: - {% include "v3/includes/_field_text.html" with name="email" label="Email" placeholder="Enter email" %} + Static server error: + {% include "v3/includes/_field_text.html" with name="email" label="Email" placeholder="Enter email" error=form.email.errors.0 %} + + Dynamic Alpine error: + {% include "v3/includes/_field_text.html" with name="title" label="Title" alpine_error="errors.title" %} + + Client-side email validation: + {% include "v3/includes/_field_text.html" with name="email" type="email" validate_type="email" validate_error="Invalid email" %} + + Search field (no error handling): + {% include "v3/includes/_field_text.html" with name="q" placeholder="Search..." submit_icon="arrow-right" submit_label="Search" %} {% endcomment %}
- {% if label %} - - {% endif %} -
+ {# djlint:off #} + {% if validate_type == "email" %} + data-validate + x-data="{ + value: '', + touched: false, + get isInvalid() { + return this.touched && this.value.length > 0 && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.value); + }, + get isEmpty() { + return this.touched && this.value.length === 0; + } + }" + :class="{ 'field--error': isInvalid || isEmpty }" + @validate="touched = true" + {% elif alpine_error %}:class="{ 'field--error': {{ alpine_error }} }"{% endif %}> + {# djlint:on #} + {% if label %}{% endif %} +
{% if icon_left %} - + {% endif %} - + {% if submit_icon %} - + {% endif %}
- {% if alpine_error %} - - {% if error %} - - {% endif %} - {% if help_text %} - + {% if validate_error %} + + {% endif %} + {% if alpine_error %} + + {% if error %}{% endif %} + {% if help_text %} +

{{ help_text }}

+ {% endif %} {% else %} - {% if error %} - - {% elif help_text %} -

{{ help_text }}

- {% endif %} + {% if error %} + + {% elif help_text %} +

{{ help_text }}

+ {% endif %} {% endif %}
diff --git a/users/password_rules.py b/users/password_rules.py new file mode 100644 index 000000000..0d817db43 --- /dev/null +++ b/users/password_rules.py @@ -0,0 +1,50 @@ +from django.conf import settings + + +def build_password_rules(): + """Generate frontend password rules from AUTH_PASSWORD_VALIDATORS. + + Each Django validator maps to one or more frontend rule dicts consumed + by the Alpine.js checklist in _field_password.html. + """ + rules = [] + for validator in settings.AUTH_PASSWORD_VALIDATORS: + name = validator["NAME"].rsplit(".", 1)[-1] + options = validator.get("OPTIONS", {}) + + if name == "MinimumLengthValidator": + min_len = options.get("min_length", 9) + rules.append( + { + "label": f"At least {min_len} characters", + "type": "min_length", + "value": min_len, + } + ) + elif name == "NumericPasswordValidator": + rules.append( + { + "label": "Can't be entirely numeric", + "type": "regex", + "value": "[^0-9]", + } + ) + elif name == "UserAttributeSimilarityValidator": + rules.append( + { + "label": "Does not contain your email address", + "type": "not_contains_field", + "value": "#field-email", + } + ) + rules.append( + { + "label": "Does not contain your username", + "type": "not_contains_field", + "value": "#field-username", + } + ) + elif name == "CommonPasswordValidator": + rules.append({"label": "Is not commonly used", "type": "server_only"}) + + return rules diff --git a/users/views.py b/users/views.py index 5f276332d..f2c9649bf 100644 --- a/users/views.py +++ b/users/views.py @@ -5,7 +5,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib import auth from django.contrib.messages.views import SuccessMessageMixin -from django.http import HttpResponseRedirect +from django.http import HttpResponseNotFound, HttpResponseRedirect from django.urls import reverse_lazy from django.views.generic import DetailView, FormView from django.views.generic.base import TemplateView @@ -21,7 +21,9 @@ from rest_framework import generics from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated, AllowAny +from waffle import flag_is_active +from core.mixins import V3Mixin from libraries.models import CommitAuthorEmail from .forms import ( PreferencesForm, @@ -30,6 +32,7 @@ DeleteAccountForm, ) from .models import User +from .password_rules import build_password_rules from .permissions import CustomUserPermissions from .serializers import UserSerializer, FullUserSerializer, CurrentUserSerializer from . import tasks @@ -296,6 +299,46 @@ def get_context_data(self, **kwargs): return context +class V3AuthContextMixin(V3Mixin): + """Shared context for all V3 auth pages (signup, login, password reset, etc.).""" + + def dispatch(self, request, *args, **kwargs): + if not flag_is_active(request, "v3"): + return HttpResponseNotFound() + return super().dispatch(request, *args, **kwargs) + + def get_v3_context_data(self, **kwargs): + context = super().get_v3_context_data(**kwargs) + context["page_title"] = getattr(self, "page_title", "Account") + context["foreground_image_url"] = ( + f"{settings.STATIC_URL}img/v3/auth-page/auth-page-foreground.png" + ) + context["background_image_url"] = ( + f"{settings.STATIC_URL}img/v3/auth-page/auth-page-background.png" + ) + context["login_url"] = reverse_lazy("v3-login") + context["signup_url"] = reverse_lazy("v3-signup") + + # Needs to be updated to V3 password reset page when that is created + context["password_reset_url"] = reverse_lazy("account_reset_password") + return context + + +class V3SignupView(V3AuthContextMixin, TemplateView): + v3_template_name = "v3/accounts/signup.html" + page_title = "Create An Account" + + def get_v3_context_data(self, **kwargs): + context = super().get_v3_context_data(**kwargs) + context["password_rules"] = build_password_rules() + return context + + +class V3LoginView(V3AuthContextMixin, TemplateView): + v3_template_name = "v3/accounts/login.html" + page_title = "Login" + + class UserAvatar(TemplateView): """ Returns the template for the user's avatar in the header from the htmx request.