All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
- Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""
|
|
Account service s-expression page components.
|
|
|
|
Renders account dashboard, newsletters, fragment pages, login, and device
|
|
auth pages. Called from route handlers in place of ``render_template()``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any
|
|
|
|
from shared.sexp.jinja_bridge import load_service_components
|
|
from shared.sexp.helpers import (
|
|
call_url, sexp_call, SexpExpr,
|
|
root_header_sexp, full_page_sexp, header_child_sexp, oob_page_sexp,
|
|
)
|
|
|
|
# Load account-specific .sexpr components at import time
|
|
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Header helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _auth_nav_sexp(ctx: dict) -> str:
|
|
"""Auth section desktop nav items."""
|
|
parts = [
|
|
sexp_call("nav-link",
|
|
href=call_url(ctx, "account_url", "/newsletters/"),
|
|
label="newsletters",
|
|
select_colours=ctx.get("select_colours", ""),
|
|
)
|
|
]
|
|
account_nav = ctx.get("account_nav")
|
|
if account_nav:
|
|
parts.append(account_nav)
|
|
return "(<> " + " ".join(parts) + ")"
|
|
|
|
|
|
def _auth_header_sexp(ctx: dict, *, oob: bool = False) -> str:
|
|
"""Build the account section header row."""
|
|
return sexp_call(
|
|
"menu-row-sx",
|
|
id="auth-row", level=1, colour="sky",
|
|
link_href=call_url(ctx, "account_url", "/"),
|
|
link_label="account", icon="fa-solid fa-user",
|
|
nav=SexpExpr(_auth_nav_sexp(ctx)),
|
|
child_id="auth-header-child", oob=oob,
|
|
)
|
|
|
|
|
|
def _auth_nav_mobile_sexp(ctx: dict) -> str:
|
|
"""Mobile nav menu for auth section."""
|
|
parts = [
|
|
sexp_call("nav-link",
|
|
href=call_url(ctx, "account_url", "/newsletters/"),
|
|
label="newsletters",
|
|
select_colours=ctx.get("select_colours", ""),
|
|
)
|
|
]
|
|
account_nav = ctx.get("account_nav")
|
|
if account_nav:
|
|
parts.append(account_nav)
|
|
return "(<> " + " ".join(parts) + ")"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Account dashboard (GET /)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _account_main_panel_sexp(ctx: dict) -> str:
|
|
"""Account info panel with user details and logout."""
|
|
from quart import g
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
|
|
user = getattr(g, "user", None)
|
|
error = ctx.get("error", "")
|
|
|
|
error_sexp = sexp_call("account-error-banner", error=error) if error else ""
|
|
|
|
user_email_sexp = ""
|
|
user_name_sexp = ""
|
|
if user:
|
|
user_email_sexp = sexp_call("account-user-email", email=user.email)
|
|
if user.name:
|
|
user_name_sexp = sexp_call("account-user-name", name=user.name)
|
|
|
|
logout_sexp = sexp_call("account-logout-form", csrf_token=generate_csrf_token())
|
|
|
|
labels_sexp = ""
|
|
if user and hasattr(user, "labels") and user.labels:
|
|
label_items = " ".join(
|
|
sexp_call("account-label-item", name=label.name)
|
|
for label in user.labels
|
|
)
|
|
labels_sexp = sexp_call("account-labels-section",
|
|
items=SexpExpr("(<> " + label_items + ")"))
|
|
|
|
return sexp_call(
|
|
"account-main-panel",
|
|
error=SexpExpr(error_sexp) if error_sexp else None,
|
|
email=SexpExpr(user_email_sexp) if user_email_sexp else None,
|
|
name=SexpExpr(user_name_sexp) if user_name_sexp else None,
|
|
logout=SexpExpr(logout_sexp),
|
|
labels=SexpExpr(labels_sexp) if labels_sexp else None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Newsletters (GET /newsletters/)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _newsletter_toggle_sexp(un: Any, account_url_fn: Any, csrf_token: str) -> str:
|
|
"""Render a single newsletter toggle switch."""
|
|
nid = un.newsletter_id
|
|
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
|
if un.subscribed:
|
|
bg = "bg-emerald-500"
|
|
translate = "translate-x-6"
|
|
checked = "true"
|
|
else:
|
|
bg = "bg-stone-300"
|
|
translate = "translate-x-1"
|
|
checked = "false"
|
|
return sexp_call(
|
|
"account-newsletter-toggle",
|
|
id=f"nl-{nid}", url=toggle_url,
|
|
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
|
target=f"#nl-{nid}",
|
|
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
|
checked=checked,
|
|
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
|
)
|
|
|
|
|
|
def _newsletter_toggle_off_sexp(nid: int, toggle_url: str, csrf_token: str) -> str:
|
|
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
|
|
return sexp_call(
|
|
"account-newsletter-toggle-off",
|
|
id=f"nl-{nid}", url=toggle_url,
|
|
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
|
target=f"#nl-{nid}",
|
|
)
|
|
|
|
|
|
def _newsletters_panel_sexp(ctx: dict, newsletter_list: list) -> str:
|
|
"""Newsletters management panel."""
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
|
|
account_url_fn = ctx.get("account_url") or (lambda p: p)
|
|
csrf = generate_csrf_token()
|
|
|
|
if newsletter_list:
|
|
items = []
|
|
for item in newsletter_list:
|
|
nl = item["newsletter"]
|
|
un = item.get("un")
|
|
|
|
desc_sexp = sexp_call(
|
|
"account-newsletter-desc", description=nl.description
|
|
) if nl.description else ""
|
|
|
|
if un:
|
|
toggle = _newsletter_toggle_sexp(un, account_url_fn, csrf)
|
|
else:
|
|
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
|
|
toggle = _newsletter_toggle_off_sexp(nl.id, toggle_url, csrf)
|
|
|
|
items.append(sexp_call(
|
|
"account-newsletter-item",
|
|
name=nl.name,
|
|
desc=SexpExpr(desc_sexp) if desc_sexp else None,
|
|
toggle=SexpExpr(toggle),
|
|
))
|
|
list_sexp = sexp_call(
|
|
"account-newsletter-list",
|
|
items=SexpExpr("(<> " + " ".join(items) + ")"),
|
|
)
|
|
else:
|
|
list_sexp = sexp_call("account-newsletter-empty")
|
|
|
|
return sexp_call("account-newsletters-panel", list=SexpExpr(list_sexp))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth pages (login, device, check_email)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _login_page_content(ctx: dict) -> str:
|
|
"""Login form content."""
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
from quart import url_for
|
|
|
|
error = ctx.get("error", "")
|
|
email = ctx.get("email", "")
|
|
action = url_for("auth.start_login")
|
|
|
|
error_sexp = sexp_call("account-login-error", error=error) if error else ""
|
|
|
|
return sexp_call(
|
|
"account-login-form",
|
|
error=SexpExpr(error_sexp) if error_sexp else None,
|
|
action=action,
|
|
csrf_token=generate_csrf_token(), email=email,
|
|
)
|
|
|
|
|
|
def _device_page_content(ctx: dict) -> str:
|
|
"""Device authorization form content."""
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
from quart import url_for
|
|
|
|
error = ctx.get("error", "")
|
|
code = ctx.get("code", "")
|
|
action = url_for("auth.device_submit")
|
|
|
|
error_sexp = sexp_call("account-device-error", error=error) if error else ""
|
|
|
|
return sexp_call(
|
|
"account-device-form",
|
|
error=SexpExpr(error_sexp) if error_sexp else None,
|
|
action=action,
|
|
csrf_token=generate_csrf_token(), code=code,
|
|
)
|
|
|
|
|
|
def _device_approved_content() -> str:
|
|
"""Device approved success content."""
|
|
return sexp_call("account-device-approved")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Account dashboard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_account_page(ctx: dict) -> str:
|
|
"""Full page: account dashboard."""
|
|
main = _account_main_panel_sexp(ctx)
|
|
|
|
hdr = root_header_sexp(ctx)
|
|
hdr_child = header_child_sexp(_auth_header_sexp(ctx))
|
|
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
|
|
|
return full_page_sexp(ctx, header_rows=header_rows,
|
|
content=main,
|
|
menu=_auth_nav_mobile_sexp(ctx))
|
|
|
|
|
|
async def render_account_oob(ctx: dict) -> str:
|
|
"""OOB response for account dashboard."""
|
|
main = _account_main_panel_sexp(ctx)
|
|
|
|
oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")"
|
|
|
|
return oob_page_sexp(oobs=oobs,
|
|
content=main,
|
|
menu=_auth_nav_mobile_sexp(ctx))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Newsletters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
|
"""Full page: newsletters."""
|
|
main = _newsletters_panel_sexp(ctx, newsletter_list)
|
|
|
|
hdr = root_header_sexp(ctx)
|
|
hdr_child = header_child_sexp(_auth_header_sexp(ctx))
|
|
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
|
|
|
return full_page_sexp(ctx, header_rows=header_rows,
|
|
content=main,
|
|
menu=_auth_nav_mobile_sexp(ctx))
|
|
|
|
|
|
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
|
"""OOB response for newsletters."""
|
|
main = _newsletters_panel_sexp(ctx, newsletter_list)
|
|
|
|
oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")"
|
|
|
|
return oob_page_sexp(oobs=oobs,
|
|
content=main,
|
|
menu=_auth_nav_mobile_sexp(ctx))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Fragment pages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
|
|
"""Full page: fragment-provided content."""
|
|
hdr = root_header_sexp(ctx)
|
|
hdr_child = header_child_sexp(_auth_header_sexp(ctx))
|
|
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
|
|
|
return full_page_sexp(ctx, header_rows=header_rows,
|
|
content=f'(~rich-text :html "{_sexp_escape(page_fragment_html)}")',
|
|
menu=_auth_nav_mobile_sexp(ctx))
|
|
|
|
|
|
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
|
|
"""OOB response for fragment pages."""
|
|
oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")"
|
|
|
|
return oob_page_sexp(oobs=oobs,
|
|
content=f'(~rich-text :html "{_sexp_escape(page_fragment_html)}")',
|
|
menu=_auth_nav_mobile_sexp(ctx))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Auth pages (login, device)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_login_page(ctx: dict) -> str:
|
|
"""Full page: login form."""
|
|
hdr = root_header_sexp(ctx)
|
|
return full_page_sexp(ctx, header_rows=hdr,
|
|
content=_login_page_content(ctx),
|
|
meta_html='<title>Login \u2014 Rose Ash</title>')
|
|
|
|
|
|
async def render_device_page(ctx: dict) -> str:
|
|
"""Full page: device authorization form."""
|
|
hdr = root_header_sexp(ctx)
|
|
return full_page_sexp(ctx, header_rows=hdr,
|
|
content=_device_page_content(ctx),
|
|
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
|
|
|
|
|
async def render_device_approved_page(ctx: dict) -> str:
|
|
"""Full page: device approved."""
|
|
hdr = root_header_sexp(ctx)
|
|
return full_page_sexp(ctx, header_rows=hdr,
|
|
content=_device_approved_content(),
|
|
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Check email page (POST /start/ success)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _check_email_content(email: str, email_error: str | None = None) -> str:
|
|
"""Check email confirmation content."""
|
|
from markupsafe import escape
|
|
|
|
error_sexp = sexp_call(
|
|
"account-check-email-error", error=str(escape(email_error))
|
|
) if email_error else ""
|
|
|
|
return sexp_call(
|
|
"account-check-email",
|
|
email=str(escape(email)),
|
|
error=SexpExpr(error_sexp) if error_sexp else None,
|
|
)
|
|
|
|
|
|
async def render_check_email_page(ctx: dict) -> str:
|
|
"""Full page: check email after magic link sent."""
|
|
email = ctx.get("email", "")
|
|
email_error = ctx.get("email_error")
|
|
hdr = root_header_sexp(ctx)
|
|
return full_page_sexp(ctx, header_rows=hdr,
|
|
content=_check_email_content(email, email_error),
|
|
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Fragment renderers for POST handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_newsletter_toggle_html(un) -> str:
|
|
"""Render a newsletter toggle switch for POST response."""
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
return _newsletter_toggle_sexp(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p,
|
|
generate_csrf_token())
|
|
|
|
|
|
def render_newsletter_toggle(un) -> str:
|
|
"""Render a newsletter toggle switch for POST response (uses account_url)."""
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
from quart import g
|
|
account_url_fn = getattr(g, "_account_url", None)
|
|
if account_url_fn is None:
|
|
from shared.infrastructure.urls import account_url
|
|
account_url_fn = account_url
|
|
return _newsletter_toggle_sexp(un, account_url_fn, generate_csrf_token())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _sexp_escape(s: str) -> str:
|
|
"""Escape a string for embedding in sexp string literals."""
|
|
return s.replace("\\", "\\\\").replace('"', '\\"')
|