""" 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.sx.jinja_bridge import load_service_components from shared.sx.helpers import ( call_url, sx_call, SxExpr, root_header_sx, full_page_sx, header_child_sx, oob_page_sx, ) # Load account-specific .sx components at import time load_service_components(os.path.dirname(os.path.dirname(__file__))) # --------------------------------------------------------------------------- # Header helpers # --------------------------------------------------------------------------- def _auth_nav_sx(ctx: dict) -> str: """Auth section desktop nav items.""" parts = [ sx_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_sx(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row.""" return sx_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=SxExpr(_auth_nav_sx(ctx)), child_id="auth-header-child", oob=oob, ) def _auth_nav_mobile_sx(ctx: dict) -> str: """Mobile nav menu for auth section.""" parts = [ sx_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_sx(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_sx = sx_call("account-error-banner", error=error) if error else "" user_email_sx = "" user_name_sx = "" if user: user_email_sx = sx_call("account-user-email", email=user.email) if user.name: user_name_sx = sx_call("account-user-name", name=user.name) logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token()) labels_sx = "" if user and hasattr(user, "labels") and user.labels: label_items = " ".join( sx_call("account-label-item", name=label.name) for label in user.labels ) labels_sx = sx_call("account-labels-section", items=SxExpr("(<> " + label_items + ")")) return sx_call( "account-main-panel", error=SxExpr(error_sx) if error_sx else None, email=SxExpr(user_email_sx) if user_email_sx else None, name=SxExpr(user_name_sx) if user_name_sx else None, logout=SxExpr(logout_sx), labels=SxExpr(labels_sx) if labels_sx else None, ) # --------------------------------------------------------------------------- # Newsletters (GET /newsletters/) # --------------------------------------------------------------------------- def _newsletter_toggle_sx(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 sx_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_sx(nid: int, toggle_url: str, csrf_token: str) -> str: """Render an unsubscribed newsletter toggle (no subscription record yet).""" return sx_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_sx(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_sx = sx_call( "account-newsletter-desc", description=nl.description ) if nl.description else "" if un: toggle = _newsletter_toggle_sx(un, account_url_fn, csrf) else: toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf) items.append(sx_call( "account-newsletter-item", name=nl.name, desc=SxExpr(desc_sx) if desc_sx else None, toggle=SxExpr(toggle), )) list_sx = sx_call( "account-newsletter-list", items=SxExpr("(<> " + " ".join(items) + ")"), ) else: list_sx = sx_call("account-newsletter-empty") return sx_call("account-newsletters-panel", list=SxExpr(list_sx)) # --------------------------------------------------------------------------- # 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_sx = sx_call("account-login-error", error=error) if error else "" return sx_call( "account-login-form", error=SxExpr(error_sx) if error_sx 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_sx = sx_call("account-device-error", error=error) if error else "" return sx_call( "account-device-form", error=SxExpr(error_sx) if error_sx else None, action=action, csrf_token=generate_csrf_token(), code=code, ) def _device_approved_content() -> str: """Device approved success content.""" return sx_call("account-device-approved") # --------------------------------------------------------------------------- # Public API: Account dashboard # --------------------------------------------------------------------------- async def render_account_page(ctx: dict) -> str: """Full page: account dashboard.""" main = _account_main_panel_sx(ctx) hdr = root_header_sx(ctx) hdr_child = header_child_sx(_auth_header_sx(ctx)) header_rows = "(<> " + hdr + " " + hdr_child + ")" return full_page_sx(ctx, header_rows=header_rows, content=main, menu=_auth_nav_mobile_sx(ctx)) async def render_account_oob(ctx: dict) -> str: """OOB response for account dashboard.""" main = _account_main_panel_sx(ctx) oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" return oob_page_sx(oobs=oobs, content=main, menu=_auth_nav_mobile_sx(ctx)) # --------------------------------------------------------------------------- # Public API: Newsletters # --------------------------------------------------------------------------- async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str: """Full page: newsletters.""" main = _newsletters_panel_sx(ctx, newsletter_list) hdr = root_header_sx(ctx) hdr_child = header_child_sx(_auth_header_sx(ctx)) header_rows = "(<> " + hdr + " " + hdr_child + ")" return full_page_sx(ctx, header_rows=header_rows, content=main, menu=_auth_nav_mobile_sx(ctx)) async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: """OOB response for newsletters.""" main = _newsletters_panel_sx(ctx, newsletter_list) oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" return oob_page_sx(oobs=oobs, content=main, menu=_auth_nav_mobile_sx(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_sx(ctx) hdr_child = header_child_sx(_auth_header_sx(ctx)) header_rows = "(<> " + hdr + " " + hdr_child + ")" return full_page_sx(ctx, header_rows=header_rows, content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")', menu=_auth_nav_mobile_sx(ctx)) async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str: """OOB response for fragment pages.""" oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" return oob_page_sx(oobs=oobs, content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")', menu=_auth_nav_mobile_sx(ctx)) # --------------------------------------------------------------------------- # Public API: Auth pages (login, device) # --------------------------------------------------------------------------- async def render_login_page(ctx: dict) -> str: """Full page: login form.""" hdr = root_header_sx(ctx) return full_page_sx(ctx, header_rows=hdr, content=_login_page_content(ctx), meta_html='Login \u2014 Rose Ash') async def render_device_page(ctx: dict) -> str: """Full page: device authorization form.""" hdr = root_header_sx(ctx) return full_page_sx(ctx, header_rows=hdr, content=_device_page_content(ctx), meta_html='Authorize Device \u2014 Rose Ash') async def render_device_approved_page(ctx: dict) -> str: """Full page: device approved.""" hdr = root_header_sx(ctx) return full_page_sx(ctx, header_rows=hdr, content=_device_approved_content(), meta_html='Device Authorized \u2014 Rose Ash') # --------------------------------------------------------------------------- # 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_sx = sx_call( "account-check-email-error", error=str(escape(email_error)) ) if email_error else "" return sx_call( "account-check-email", email=str(escape(email)), error=SxExpr(error_sx) if error_sx 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_sx(ctx) return full_page_sx(ctx, header_rows=hdr, content=_check_email_content(email, email_error), meta_html='Check your email \u2014 Rose Ash') # --------------------------------------------------------------------------- # Public API: Fragment renderers for POST handlers # --------------------------------------------------------------------------- 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_sx(un, account_url_fn, generate_csrf_token()) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _sx_escape(s: str) -> str: """Escape a string for embedding in sx string literals.""" return s.replace("\\", "\\\\").replace('"', '\\"')