Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customer branding 533 v2 #622

Merged
merged 63 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
c277961
refactor: convert hex primary color to easily tinted oklch #533
rmlibre Sep 10, 2024
f8a8407
feat: add branding tab to settings template page #533
rmlibre Sep 10, 2024
c67dc5d
style: add CSS section for input='color' branding element #533
rmlibre Sep 10, 2024
19c8d40
feat: add form types for persisting brand changes to server db #533
rmlibre Sep 10, 2024
ce8a5c5
feat: add preliminary routes to consume branding changes #533
rmlibre Sep 10, 2024
bfe0fe9
merge: pull 'main' updates into PR branch addressing #533
rmlibre Sep 10, 2024
d847a50
fix: use consistent route admin-required & redirect logic #573
rmlibre Sep 12, 2024
bd6c6a1
feat: apply single-source color theme transformations to CSS #573
rmlibre Sep 12, 2024
75269c7
fix: add hex color code validator consistent with input element #573
rmlibre Sep 12, 2024
241b1df
fix: give brand form data to renamed db object attributes #573
rmlibre Sep 12, 2024
5f246a8
fix: remove redundant length validator covered by ``HexColor`` #573
rmlibre Sep 12, 2024
f421b9b
fix: match template structure for brand color & name sections #573
rmlibre Sep 13, 2024
e397e8f
refactor: use input type specifier & relocate CSS section #573
rmlibre Sep 13, 2024
ed8a92f
feat: apply CSS changes on JS input & change color events #573
rmlibre Sep 13, 2024
4349394
feat: apply more accessible & stable color transformations #573
rmlibre Sep 13, 2024
9593477
fix: use oklch instead of lch in JS & remove force reflow #573
rmlibre Sep 14, 2024
09f7f73
fix: clamp max lightness lower to keep light color saturation #573
rmlibre Sep 14, 2024
c1ace76
feat: create new ``HostOrganization`` table for instance data #573
rmlibre Sep 14, 2024
bdc00e4
feat: install ``HostOrganization`` record into jinja templates #573
rmlibre Sep 14, 2024
1352dfc
fix: rename front/back -end forms & db object for proper comms #573
rmlibre Sep 14, 2024
e2e1f24
fix: add CSRF tokens to new brand color & name forms #573
rmlibre Sep 14, 2024
499455f
fix: import & render custom persisted branding configuration #573
rmlibre Sep 14, 2024
b63b76a
fix: return failure status code when db in incoherent state #573
rmlibre Sep 14, 2024
1cda43d
feat: update page h1 when app name is being typed #573
rmlibre Sep 14, 2024
93ddcf5
feat: update page h1 with host's brand name from the database #573
rmlibre Sep 14, 2024
7320596
refactor: clean up redundant &/or unused values #573
rmlibre Sep 14, 2024
25c49d2
feat: validate app names are canonical under html conversions #573
rmlibre Sep 14, 2024
d745fdd
refactor: remove redundant form validator #573
rmlibre Sep 14, 2024
7c390b5
revert: "refactor: remove redundant form validator #573"
rmlibre Sep 14, 2024
2212776
fix: apply admin-only controls for rendering branding features #573
rmlibre Sep 14, 2024
4ecc67f
fix: increase contrast ratios to minimum recommendations #573
rmlibre Sep 15, 2024
2ab4fa4
fix: require data for brand app name form to be sent #573
rmlibre Sep 15, 2024
a236353
fix: make dark mode brand colors more vibrant #573
rmlibre Sep 15, 2024
55d0a5d
fix: apply only medium contrast tinting for bold text #573
rmlibre Sep 15, 2024
6d7eaff
refactor: improve the readability of JS code additions #573
rmlibre Sep 16, 2024
47d49da
style: adjust jinja formatting to reduce extra whitespace #573
rmlibre Sep 16, 2024
e9ca02d
fix: remove early creation of all db tables #573
rmlibre Sep 16, 2024
ab0a212
fix: limit hex color-code db field length to 7 characters #573
rmlibre Sep 16, 2024
3770354
fix: improve dark themes accuracy, dynamism, & vibrancy #573
rmlibre Sep 17, 2024
7f154fe
lint
jeremywmoore Sep 17, 2024
59a060b
Merge branch 'main' into customer_branding_533
jeremywmoore Sep 17, 2024
0984263
fix: make dark danger button border more subtle #573
rmlibre Sep 17, 2024
65c183f
fix: convert the dark hamburger menu icon to color neutral #573
rmlibre Sep 17, 2024
c26f8ef
merge: pull remote PR fixes into local PR branch #573
rmlibre Sep 17, 2024
ec2e617
fix: apply subtle theme color to dark menu label text #573
rmlibre Sep 17, 2024
416db1b
fix: improve dark theme accessibility with wider contrasts #573
rmlibre Sep 17, 2024
2cf9518
docs: provide hex color code example in error message #573
rmlibre Sep 18, 2024
007cf1b
refactor: apply DRY principles & avoid magic value use #573
rmlibre Sep 18, 2024
4097ffe
fix(exc): log missing ``host_org`` record error state #573
rmlibre Sep 18, 2024
06042b7
style: attempt prettier ignore on HTML style element #573
rmlibre Sep 18, 2024
ebb219b
Merge branch 'main' into customer_branding_533_v2
brassy-endomorph Sep 28, 2024
9cad571
rename HostOrganization.default() to fetch_or_default() for clarity
brassy-endomorph Sep 28, 2024
8d096fd
log missing HostOrg on app startup
brassy-endomorph Sep 28, 2024
90cbf25
avoid hiding errors in Makefile
brassy-endomorph Sep 28, 2024
8294ffe
idiomatically use super() constructors in DB models
brassy-endomorph Sep 28, 2024
3e22867
app branding tests
brassy-endomorph Sep 28, 2024
3067628
require single tenant instances for updating brand settings
brassy-endomorph Sep 28, 2024
9674b1e
migration for host_organization table
brassy-endomorph Sep 28, 2024
8e02dc9
silence migrations deprecation warning
brassy-endomorph Sep 30, 2024
14fe514
tests to check for updated strings in response html
brassy-endomorph Sep 30, 2024
d014458
update HostOrganization constructor to always return a fully populate…
brassy-endomorph Sep 30, 2024
886a2f0
make registration codes an app config
brassy-endomorph Sep 30, 2024
246ae1b
roll back multi-tenancy logic for brand settings updates
brassy-endomorph Sep 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ migrate-prod: ## Run prod env (alembic) migrations
lint: ## Lint the code
poetry run ruff format --check && \
poetry run ruff check && \
poetry run mypy .
poetry run mypy . && \
docker compose run --rm app npx prettier --check ./*.md ./docs ./.github/workflows/* ./hushline

.PHONY: fix
fix: ## Format the code
poetry run ruff format && \
poetry run ruff check --fix
poetry run ruff check --fix && \
docker compose run --rm app npx prettier --write ./*.md ./docs ./.github/workflows/* ./hushline

.PHONY: revision
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ services:
ENCRYPTION_KEY: bi5FDwhZGKfc4urLJ_ChGtIAaOPgxd3RDOhnvct10mw=
SECRET_KEY: cb3f4afde364bfb3956b97ca22ef4d2b593d9d980a4330686267cabcd2c0befd
SQLALCHEMY_DATABASE_URI: postgresql://hushline:hushline@postgres:5432/hushline
REGISTRATION_CODES_REQUIRED: False
REGISTRATION_CODES_REQUIRED: 'false'
SESSION_COOKIE_NAME: session
NOTIFICATIONS_ADDRESS: notifications@hushline.app
volumes:
Expand Down
23 changes: 22 additions & 1 deletion hushline/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
from flask import Flask, flash, redirect, request, session, url_for
from flask_migrate import Migrate
from jinja2 import StrictUndefined
from sqlalchemy.exc import ProgrammingError
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.wrappers.response import Response

from . import admin, routes, settings
from .db import db
from .model import User
from .model import HostOrganization, User
from .version import __version__


Expand Down Expand Up @@ -47,6 +48,9 @@ def create_app() -> Flask:
app.config["IS_PERSONAL_SERVER"] = (
os.environ.get("IS_PERSONAL_SERVER", "False").lower() == "true"
)
app.config["REGISTRATION_CODES_REQUIRED"] = (
os.environ.get("REGISTRATION_CODES_REQUIRED", "true").lower() == "true"
)
app.config["NOTIFICATIONS_ADDRESS"] = os.environ.get("NOTIFICATIONS_ADDRESS", None)
app.config["SMTP_USERNAME"] = os.environ.get("SMTP_USERNAME", None)
app.config["SMTP_SERVER"] = os.environ.get("SMTP_SERVER", None)
Expand Down Expand Up @@ -90,6 +94,10 @@ def inject_user() -> dict[str, Any]:
return {"user": user}
return {}

@app.context_processor
def inject_host() -> dict[str, HostOrganization]:
return dict(host_org=HostOrganization.fetch_or_default())

@app.context_processor
def inject_is_personal_server() -> dict[str, Any]:
return {"is_personal_server": app.config["IS_PERSONAL_SERVER"]}
Expand All @@ -104,4 +112,17 @@ def add_onion_location_header(response: Response) -> Response:
)
return response

# we can't
brassy-endomorph marked this conversation as resolved.
Show resolved Hide resolved
if app.config.get("FLASK_ENV", None) != "development":
with app.app_context():
try:
host_org = HostOrganization.fetch()
except ProgrammingError:
app.logger.warning(
"Could not check for existence of HostOrganization", exc_info=True
)
else:
if host_org is None:
app.logger.warning("HostOrganization data not found in database.")

return app
18 changes: 18 additions & 0 deletions hushline/forms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import html
import re

from flask_wtf import FlaskForm
Expand Down Expand Up @@ -25,5 +26,22 @@ def __call__(self, form: Form, field: Field) -> None:
raise ValidationError(self.message)


class HexColor:
# HTML input color elements only give & accept 6-hexit color codes
hex_color_regex: re.Pattern = re.compile(r"^#[0-9a-fA-F]{6}$")

def __call__(self, form: Form, field: Field) -> None:
color: str = field.data
if not self.hex_color_regex.match(color):
raise ValidationError(f"{color=} is an invalid 6-hexit color code. (eg. #7d25c1)")


class CanonicalHTML:
def __call__(self, form: Form, field: Field) -> None:
text: str = field.data
if text != html.escape(text).strip():
raise ValidationError(f"{text=} is ambiguous or unescaped.")


class TwoFactorForm(FlaskForm):
verification_code = StringField("2FA Code", validators=[DataRequired(), Length(min=6, max=6)])
76 changes: 61 additions & 15 deletions hushline/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Generator, Optional, Sequence
from typing import TYPE_CHECKING, Any, Generator, Optional, Self, Sequence

from flask_sqlalchemy.model import Model
from passlib.hash import scrypt
Expand All @@ -28,6 +28,46 @@ def default(cls) -> "SMTPEncryption":
return cls.StartTLS


class HostOrganization(Model):
__tablename__ = "host_organization"

_DEFAULT_ID: int = 1
_DEFAULT_BRAND_PRIMARY_HEX_COLOR: str = "#7d25c1"
_DEFAULT_BRAND_APP_NAME: str = "🤫 Hush Line"

id: Mapped[int] = mapped_column(primary_key=True)
brand_app_name: Mapped[str] = mapped_column(db.String(255), default=_DEFAULT_BRAND_APP_NAME)
brand_primary_hex_color: Mapped[str] = mapped_column(
db.String(7), default=_DEFAULT_BRAND_PRIMARY_HEX_COLOR
)

@classmethod
def fetch(cls) -> Self | None:
return db.session.get(cls, cls._DEFAULT_ID)

@classmethod
def fetch_or_default(cls) -> Self:
return cls.fetch() or cls()

def __init__(self, **kwargs: Any) -> None:
# never allow setting of the ID. It should only ever be `1`
if "id" in kwargs:
raise ValueError(f"Cannot manually set {self.__class__.__name__} attribute `id`")

# always initialize all values so that the object is populated for use in templates
# even when it's not pulled from the DB.
# yes, we have to do this here and not rely on `mapped_column(default='...')` because
# that logic doesn't trigger until insert, and we want these here pre-insert
if "brand_app_name" not in kwargs:
kwargs["brand_app_name"] = self._DEFAULT_BRAND_APP_NAME
if "brand_primary_hex_color" not in kwargs:
kwargs["brand_primary_hex_color"] = self._DEFAULT_BRAND_PRIMARY_HEX_COLOR
super().__init__(
id=self._DEFAULT_ID, # type: ignore[call-arg]
**kwargs,
)


@dataclass(frozen=True, repr=False, eq=False)
class ExtraField:
label: Optional[str]
Expand Down Expand Up @@ -73,9 +113,11 @@ def __init__(
is_primary: bool,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._username = _username
self.is_primary = is_primary
super().__init__(
_username=_username, # type: ignore[call-arg]
is_primary=is_primary, # type: ignore[call-arg]
**kwargs,
)

@property
def username(self) -> str:
Expand Down Expand Up @@ -219,7 +261,7 @@ def __init__(self, **kwargs: Any) -> None:
if key in kwargs:
raise ValueError(f"Key {key!r} cannot be mannually set. Try 'password' instead.")
pw = kwargs.pop("password", None)
super().__init__()
super().__init__(**kwargs)
self.password_hash = pw


Expand Down Expand Up @@ -255,11 +297,12 @@ def __init__(
otp_code: str | None = None,
timecode: int | None = None,
) -> None:
super().__init__()
self.user_id = user_id
self.successful = successful
self.otp_code = otp_code
self.timecode = timecode
super().__init__(
user_id=user_id, # type: ignore[call-arg]
successful=successful, # type: ignore[call-arg]
otp_code=otp_code, # type: ignore[call-arg]
timecode=timecode, # type: ignore[call-arg]
)


class Message(Model):
Expand All @@ -273,8 +316,10 @@ class Message(Model):
def __init__(self, content: str, **kwargs: Any) -> None:
if "_content" in kwargs:
raise ValueError("Cannot set '_content' directly. Use 'content'")
super().__init__(**kwargs)
self.content = content
super().__init__(
content=content, # type: ignore[call-arg]
**kwargs,
)

@property
def content(self) -> str | None:
Expand All @@ -297,9 +342,10 @@ class InviteCode(Model):
expiration_date: Mapped[datetime]

def __init__(self) -> None:
super().__init__()
self.code = secrets.token_urlsafe(16)
self.expiration_date = datetime.now(timezone.utc) + timedelta(days=365)
super().__init__(
code=secrets.token_urlsafe(16), # type: ignore[call-arg]
expiration_date=datetime.now(timezone.utc) + timedelta(days=365), # type: ignore[call-arg]
)

def __repr__(self) -> str:
return f"<InviteCode {self.code}>"
3 changes: 1 addition & 2 deletions hushline/routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
import os
import re
import secrets
import socket
Expand Down Expand Up @@ -320,7 +319,7 @@ def register() -> Response | str | tuple[Response | str, int]:
flash("👉 You are already logged in.")
return redirect(url_for("inbox"))

require_invite_code = os.environ.get("REGISTRATION_CODES_REQUIRED", "True") == "True"
require_invite_code = app.config["REGISTRATION_CODES_REQUIRED"]
form = RegistrationForm()
if not require_invite_code:
del form.invite_code
Expand Down
44 changes: 42 additions & 2 deletions hushline/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@
from ..crypto import is_valid_pgp_key
from ..db import db
from ..forms import TwoFactorForm
from ..model import Message, SMTPEncryption, User, Username
from ..utils import authentication_required, create_smtp_config
from ..model import HostOrganization, Message, SMTPEncryption, User, Username
from ..utils import (
admin_authentication_required,
authentication_required,
create_smtp_config,
)
from .forms import (
ChangePasswordForm,
ChangeUsernameForm,
Expand All @@ -37,6 +41,8 @@
PGPKeyForm,
PGPProtonForm,
ProfileForm,
UpdateBrandAppNameForm,
UpdateBrandPrimaryColorForm,
)


Expand Down Expand Up @@ -215,6 +221,8 @@ async def index() -> str | Response:
directory_visibility_form = DirectoryVisibilityForm()
new_alias_form = NewAliasForm()
profile_form = ProfileForm()
update_brand_primary_color_form = UpdateBrandPrimaryColorForm()
update_brand_app_name_form = UpdateBrandAppNameForm()

if request.method == "POST":
if "update_bio" in request.form and profile_form.validate_on_submit():
Expand Down Expand Up @@ -295,6 +303,8 @@ async def index() -> str | Response:
"settings/index.html",
user=user,
all_users=all_users,
update_brand_primary_color_form=update_brand_primary_color_form,
update_brand_app_name_form=update_brand_app_name_form,
email_forwarding_form=email_forwarding_form,
change_password_form=change_password_form,
change_username_form=change_username_form,
Expand Down Expand Up @@ -605,6 +615,36 @@ def update_smtp_settings() -> Response | str:
flash("👍 SMTP settings updated successfully")
return redirect(url_for(".index"))

@bp.route("/update-brand-primary-color", methods=["POST"])
@admin_authentication_required
def update_brand_primary_color() -> Response | str:
host_org = HostOrganization.fetch_or_default()
brassy-endomorph marked this conversation as resolved.
Show resolved Hide resolved
form = UpdateBrandPrimaryColorForm()
if form.validate_on_submit():
host_org.brand_primary_hex_color = form.brand_primary_hex_color.data
db.session.add(host_org) # explicitly add because instance might be new
db.session.commit()
flash("👍 Brand primary color updated successfully.")
return redirect(url_for(".index"))

flash("⛔ Invalid form data. Please try again.")
return redirect(url_for(".index"))

@bp.route("/update-brand-app-name", methods=["POST"])
@admin_authentication_required
def update_brand_app_name() -> Response | str:
host_org = HostOrganization.fetch_or_default()
brassy-endomorph marked this conversation as resolved.
Show resolved Hide resolved
form = UpdateBrandAppNameForm()
if form.validate_on_submit():
host_org.brand_app_name = form.brand_app_name.data
db.session.add(host_org) # explicitly add because instance might be new
db.session.commit()
flash("👍 Brand app name updated successfully.")
return redirect(url_for(".index"))

flash("⛔ Invalid form data. Please try again.")
return redirect(url_for(".index"))

@bp.route("/delete-account", methods=["POST"])
@authentication_required
def delete_account() -> Response | str:
Expand Down
12 changes: 11 additions & 1 deletion hushline/settings/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from wtforms.validators import DataRequired, Email, Length
from wtforms.validators import Optional as OptionalField

from ..forms import ComplexPassword
from ..forms import CanonicalHTML, ComplexPassword, HexColor
from ..model import SMTPEncryption


Expand Down Expand Up @@ -173,3 +173,13 @@ class ProfileForm(FlaskForm):
filters=[strip_whitespace],
validators=[OptionalField(), Length(max=4096)],
)


class UpdateBrandPrimaryColorForm(FlaskForm):
brand_primary_hex_color = StringField("Hex Color", validators=[DataRequired(), HexColor()])


class UpdateBrandAppNameForm(FlaskForm):
brand_app_name = StringField(
"App Name", validators=[CanonicalHTML(), DataRequired(), Length(min=2, max=30)]
)
Loading
Loading