""" 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 from typing import Any from shared.sexp.jinja_bridge import sexp from shared.sexp.helpers import ( call_url, root_header_html, search_desktop_html, search_mobile_html, full_page, oob_page, ) # --------------------------------------------------------------------------- # Header helpers # --------------------------------------------------------------------------- def _auth_nav_html(ctx: dict) -> str: """Auth section desktop nav items.""" html = sexp( '(~nav-link :href h :label "newsletters" :select-colours sc)', h=call_url(ctx, "account_url", "/newsletters/"), sc=ctx.get("select_colours", ""), ) account_nav_html = ctx.get("account_nav_html", "") if account_nav_html: html += account_nav_html return html def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row.""" return sexp( '(~menu-row :id "auth-row" :level 1 :colour "sky"' ' :link-href lh :link-label "account" :icon "fa-solid fa-user"' ' :nav-html nh :child-id "auth-header-child" :oob oob)', lh=call_url(ctx, "account_url", "/"), nh=_auth_nav_html(ctx), oob=oob, ) def _auth_nav_mobile_html(ctx: dict) -> str: """Mobile nav menu for auth section.""" html = sexp( '(~nav-link :href h :label "newsletters" :select-colours sc)', h=call_url(ctx, "account_url", "/newsletters/"), sc=ctx.get("select_colours", ""), ) account_nav_html = ctx.get("account_nav_html", "") if account_nav_html: html += account_nav_html return html # --------------------------------------------------------------------------- # Account dashboard (GET /) # --------------------------------------------------------------------------- def _account_main_panel_html(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_html = sexp( '(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm" (raw! e))', e=error, ) if error else "" user_email_html = "" user_name_html = "" if user: user_email_html = sexp( '(p :class "text-sm text-stone-500 mt-1" (raw! e))', e=user.email, ) if user.name: user_name_html = sexp( '(p :class "text-sm text-stone-600" (raw! n))', n=user.name, ) logout_html = sexp( '(form :action "/auth/logout/" :method "post"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (button :type "submit"' ' :class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"' ' (i :class "fa-solid fa-right-from-bracket text-xs") " Sign out"))', csrf=generate_csrf_token(), ) labels_html = "" if user and hasattr(user, "labels") and user.labels: label_items = "".join( sexp( '(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60" (raw! n))', n=label.name, ) for label in user.labels ) labels_html = sexp( '(div (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")' ' (div :class "flex flex-wrap gap-2" (raw! items)))', items=label_items, ) return sexp( '(div :class "w-full max-w-3xl mx-auto px-4 py-6"' ' (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"' ' (raw! err)' ' (div :class "flex items-center justify-between"' ' (div (h1 :class "text-xl font-semibold tracking-tight" "Account") (raw! email) (raw! name))' ' (raw! logout))' ' (raw! labels)))', err=error_html, email=user_email_html, name=user_name_html, logout=logout_html, labels=labels_html, ) # --------------------------------------------------------------------------- # Newsletters (GET /newsletters/) # --------------------------------------------------------------------------- def _newsletter_toggle_html(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( '(div :id id :class "flex items-center"' ' (button :hx-post url :hx-headers hdrs :hx-target tgt :hx-swap "outerHTML"' ' :class cls :role "switch" :aria-checked checked' ' (span :class knob)))', id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', tgt=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=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}", ) def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str: """Render an unsubscribed newsletter toggle (no subscription record yet).""" return sexp( '(div :id id :class "flex items-center"' ' (button :hx-post url :hx-headers hdrs :hx-target tgt :hx-swap "outerHTML"' ' :class "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-stone-300"' ' :role "switch" :aria-checked "false"' ' (span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1")))', id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', tgt=f"#nl-{nid}", ) def _newsletters_panel_html(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_html = sexp( '(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! d))', d=nl.description, ) if nl.description else "" if un: toggle = _newsletter_toggle_html(un, account_url_fn, csrf) else: toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf) items.append(sexp( '(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"' ' (div :class "min-w-0 flex-1"' ' (p :class "text-sm font-medium text-stone-800" (raw! name))' ' (raw! desc))' ' (div :class "ml-4 flex-shrink-0" (raw! toggle)))', name=nl.name, desc=desc_html, toggle=toggle, )) list_html = sexp( '(div :class "divide-y divide-stone-100" (raw! items))', items="".join(items), ) else: list_html = sexp('(p :class "text-sm text-stone-500" "No newsletters available.")') return sexp( '(div :class "w-full max-w-3xl mx-auto px-4 py-6"' ' (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"' ' (h1 :class "text-xl font-semibold tracking-tight" "Newsletters")' ' (raw! list)))', list=list_html, ) # --------------------------------------------------------------------------- # 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_html = sexp( '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', e=error, ) if error else "" return sexp( '(div :class "py-8 max-w-md mx-auto"' ' (h1 :class "text-2xl font-bold mb-6" "Sign in")' ' (raw! err)' ' (form :method "post" :action action :class "space-y-4"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (div' ' (label :for "email" :class "block text-sm font-medium mb-1" "Email address")' ' (input :type "email" :name "email" :id "email" :value email :required true :autofocus true' ' :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))' ' (button :type "submit"' ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' ' "Send magic link")))', err=error_html, action=action, csrf=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_html = sexp( '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', e=error, ) if error else "" return sexp( '(div :class "py-8 max-w-md mx-auto"' ' (h1 :class "text-2xl font-bold mb-6" "Authorize device")' ' (p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")' ' (raw! err)' ' (form :method "post" :action action :class "space-y-4"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (div' ' (label :for "code" :class "block text-sm font-medium mb-1" "Device code")' ' (input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX"' ' :required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false"' ' :class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"))' ' (button :type "submit"' ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' ' "Authorize")))', err=error_html, action=action, csrf=generate_csrf_token(), code=code, ) def _device_approved_content() -> str: """Device approved success content.""" return sexp( '(div :class "py-8 max-w-md mx-auto text-center"' ' (h1 :class "text-2xl font-bold mb-4" "Device authorized")' ' (p :class "text-stone-600" "You can close this window and return to your terminal."))', ) # --------------------------------------------------------------------------- # Public API: Account dashboard # --------------------------------------------------------------------------- async def render_account_page(ctx: dict) -> str: """Full page: account dashboard.""" main = _account_main_panel_html(ctx) hdr = root_header_html(ctx) hdr += sexp( '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))', a=_auth_header_html(ctx), ) return full_page(ctx, header_rows_html=hdr, content_html=main, menu_html=_auth_nav_mobile_html(ctx)) async def render_account_oob(ctx: dict) -> str: """OOB response for account dashboard.""" main = _account_main_panel_html(ctx) oobs = ( _auth_header_html(ctx, oob=True) + root_header_html(ctx, oob=True) ) return oob_page(ctx, oobs_html=oobs, content_html=main, menu_html=_auth_nav_mobile_html(ctx)) # --------------------------------------------------------------------------- # Public API: Newsletters # --------------------------------------------------------------------------- async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str: """Full page: newsletters.""" main = _newsletters_panel_html(ctx, newsletter_list) hdr = root_header_html(ctx) hdr += sexp( '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))', a=_auth_header_html(ctx), ) return full_page(ctx, header_rows_html=hdr, content_html=main, menu_html=_auth_nav_mobile_html(ctx)) async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: """OOB response for newsletters.""" main = _newsletters_panel_html(ctx, newsletter_list) oobs = ( _auth_header_html(ctx, oob=True) + root_header_html(ctx, oob=True) ) return oob_page(ctx, oobs_html=oobs, content_html=main, menu_html=_auth_nav_mobile_html(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_html(ctx) hdr += sexp( '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))', a=_auth_header_html(ctx), ) return full_page(ctx, header_rows_html=hdr, content_html=page_fragment_html, menu_html=_auth_nav_mobile_html(ctx)) async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str: """OOB response for fragment pages.""" oobs = ( _auth_header_html(ctx, oob=True) + root_header_html(ctx, oob=True) ) return oob_page(ctx, oobs_html=oobs, content_html=page_fragment_html, menu_html=_auth_nav_mobile_html(ctx)) # --------------------------------------------------------------------------- # Public API: Auth pages (login, device) # --------------------------------------------------------------------------- async def render_login_page(ctx: dict) -> str: """Full page: login form.""" hdr = root_header_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=_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_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=_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_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=_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_html = sexp( '(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))', e=str(escape(email_error)), ) if email_error else "" return sexp( '(div :class "py-8 max-w-md mx-auto text-center"' ' (h1 :class "text-2xl font-bold mb-4" "Check your email")' ' (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")' ' (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")' ' (raw! err))', email=str(escape(email)), err=error_html, ) 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_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=_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_html(un) -> str: """Render a newsletter toggle switch for POST response.""" from shared.browser.app.csrf import generate_csrf_token return _newsletter_toggle_html(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: # Fallback: construct URL directly from shared.infrastructure.urls import account_url account_url_fn = account_url return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token())