diff --git a/bp/auth/routes.py b/bp/auth/routes.py index 658a954..34f388c 100644 --- a/bp/auth/routes.py +++ b/bp/auth/routes.py @@ -1,7 +1,7 @@ """Authentication routes for the federation app. -Ported from blog/bp/auth/routes.py — owns magic link login/logout. -Simplified: no Ghost sync, no newsletter management (those stay in blog). +Ported from blog/bp/auth/routes.py — owns magic link login/logout +plus the OOB account page system (newsletters, widget pages). """ from __future__ import annotations @@ -22,7 +22,9 @@ from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from shared.db.session import get_session -from shared.models import User +from shared.models import User, UserNewsletter +from shared.models.ghost_membership_entities import GhostNewsletter +from shared.services.widget_registry import widgets from shared.config import config from shared.utils import host_url from shared.infrastructure.urls import federation_url @@ -42,10 +44,25 @@ from .services import ( SESSION_USER_KEY = "uid" +oob = { + "oob_extends": "oob_elements.html", + "extends": "_types/root/_index.html", + "parent_id": "root-header-child", + "child_id": "auth-header-child", + "header": "_types/auth/header/_header.html", + "parent_header": "_types/root/header/_header.html", + "nav": "_types/auth/_nav.html", + "main": "_types/auth/_main_panel.html", +} + def register(url_prefix="/auth"): auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix) + @auth_bp.context_processor + def context(): + return {"oob": oob, "account_nav_links": widgets.account_nav} + @auth_bp.get("/login/") async def login_form(): store_login_redirect_target() @@ -58,14 +75,91 @@ def register(url_prefix="/auth"): @auth_bp.get("/account/") async def account(): + from shared.browser.app.utils.htmx import is_htmx_request + if not g.get("user"): return redirect(host_url(url_for("auth.login_form"))) - # Check if user has an ActorProfile - actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) + if not is_htmx_request(): + html = await render_template("_types/auth/index.html") + else: + html = await render_template("_types/auth/_oob_elements.html") + + return await make_response(html) + + @auth_bp.get("/newsletters/") + async def newsletters(): + from shared.browser.app.utils.htmx import is_htmx_request + + if not g.get("user"): + return redirect(host_url(url_for("auth.login_form"))) + + result = await g.s.execute( + select(GhostNewsletter).order_by(GhostNewsletter.name) + ) + all_newsletters = result.scalars().all() + + sub_result = await g.s.execute( + select(UserNewsletter).where( + UserNewsletter.user_id == g.user.id, + ) + ) + user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()} + + newsletter_list = [] + for nl in all_newsletters: + un = user_subs.get(nl.id) + newsletter_list.append({ + "newsletter": nl, + "un": un, + "subscribed": un.subscribed if un else False, + }) + + nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"} + + if not is_htmx_request(): + html = await render_template( + "_types/auth/index.html", + oob=nl_oob, + newsletter_list=newsletter_list, + ) + else: + html = await render_template( + "_types/auth/_oob_elements.html", + oob=nl_oob, + newsletter_list=newsletter_list, + ) + + return await make_response(html) + + @auth_bp.post("/newsletter//toggle/") + async def toggle_newsletter(newsletter_id: int): + if not g.get("user"): + return "", 401 + + result = await g.s.execute( + select(UserNewsletter).where( + UserNewsletter.user_id == g.user.id, + UserNewsletter.newsletter_id == newsletter_id, + ) + ) + un = result.scalar_one_or_none() + + if un: + un.subscribed = not un.subscribed + else: + un = UserNewsletter( + user_id=g.user.id, + newsletter_id=newsletter_id, + subscribed=True, + ) + g.s.add(un) + + await g.s.flush() + return await render_template( - "federation/account.html", - actor=actor, + "_types/auth/_newsletter_toggle.html", + un=un, ) @auth_bp.post("/start/") @@ -168,4 +262,35 @@ def register(url_prefix="/auth"): qsession.pop(SESSION_USER_KEY, None) return redirect(federation_url("/")) + # Catch-all for widget pages — must be last to avoid shadowing specific routes + @auth_bp.get("//") + async def widget_page(slug): + from shared.browser.app.utils.htmx import is_htmx_request + from quart import abort + + widget = widgets.account_page_by_slug(slug) + if not widget: + abort(404) + + if not g.get("user"): + return redirect(host_url(url_for("auth.login_form"))) + + ctx = await widget.context_fn(g.s, user_id=g.user.id) + w_oob = {**oob, "main": widget.template} + + if not is_htmx_request(): + html = await render_template( + "_types/auth/index.html", + oob=w_oob, + **ctx, + ) + else: + html = await render_template( + "_types/auth/_oob_elements.html", + oob=w_oob, + **ctx, + ) + + return await make_response(html) + return auth_bp diff --git a/shared b/shared index bd18d0b..ea8e7da 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit bd18d0befc19c2b43b9dc0c3b34ded663442d9ed +Subproject commit ea8e7da9d4c8424b751bb3e49ce8ac4f7be85d5c