from __future__ import annotations
from importlib import resources
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from jinja2 import (
BaseLoader,
ChoiceLoader,
Environment,
FileSystemLoader,
PackageLoader,
select_autoescape,
)
if TYPE_CHECKING:
from regstack.app import RegStack
_PACKAGE = "regstack.ui"
_TEMPLATE_DIR = "templates"
_STATIC_DIR = "static"
PAGE_NAMES = (
"login",
"register",
"verify",
"forgot",
"reset",
"me",
"confirm-email-change",
"mfa-confirm",
"oauth-complete",
)
[docs]
def default_static_dir() -> Path:
"""Return the filesystem path to the bundled static assets.
Resolves ``regstack/ui/static/`` from the installed package via
``importlib.resources``, so it works whether regstack is in an
editable install or a wheel. Used by the ``StaticFiles`` factory
on :class:`~regstack.app.RegStack`.
Returns:
Filesystem path containing ``css/core.css``, ``css/theme.css``,
and ``js/regstack.js``.
"""
return Path(str(resources.files(_PACKAGE).joinpath(_STATIC_DIR)))
[docs]
def build_ui_environment(host_template_dirs: list[Path] | None = None) -> Environment:
"""Construct the Jinja2 environment used by the SSR pages.
Wraps a :class:`jinja2.ChoiceLoader` so that **host directories
are searched first**, falling back to the bundled templates from
the regstack package. A host can override ``auth/login.html``
(or any other bundled template) by dropping a same-named file
into one of the supplied directories.
Args:
host_template_dirs: Optional list of host template directories
to prepend. Order matters — earlier entries win on
collisions.
Returns:
A configured :class:`jinja2.Environment` with autoescape on
for HTML.
"""
loaders: list[BaseLoader] = [FileSystemLoader(str(p)) for p in (host_template_dirs or [])]
loaders.append(PackageLoader(_PACKAGE, _TEMPLATE_DIR))
return Environment(
loader=ChoiceLoader(loaders),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
)
[docs]
def build_ui_router(rs: RegStack) -> APIRouter:
"""Build the SSR :class:`APIRouter` for the bundled HTML pages.
Mounts a ``GET`` endpoint for each of :data:`PAGE_NAMES`
(``login``, ``register``, ``verify``, ``forgot``, ``reset``,
``me``, ``confirm-email-change``, ``mfa-confirm``).
Pages are **stateless** — they render the same HTML regardless of
auth state. The bundled ``regstack.js`` reads the API and UI
prefixes from ``<body data-rs-api data-rs-ui>``, drives form
submissions via ``fetch``, and stores the access token in
``localStorage``. No cookie session is established here, so this
router is safe to mount alongside the JSON API without CSRF
middleware.
Args:
rs: The owning :class:`~regstack.app.RegStack` instance — its
config drives the page context (brand, prefixes, theme
URL) and its template environment is reused.
Returns:
A FastAPI ``APIRouter`` ready for ``app.include_router(...,
prefix=config.ui_prefix)``.
"""
router = APIRouter()
env = rs.ui_env
def _render(template_name: str, page: str, **extra: object) -> HTMLResponse:
ctx = _base_context(rs, page=page)
ctx.update(extra)
body = env.get_template(template_name).render(ctx)
return HTMLResponse(body)
@router.get("/login", response_class=HTMLResponse, summary="Sign-in page")
async def login_page(_request: Request) -> HTMLResponse:
return _render("auth/login.html", page="login")
@router.get("/register", response_class=HTMLResponse, summary="Account creation page")
async def register_page(_request: Request) -> HTMLResponse:
return _render("auth/register.html", page="register")
@router.get(
"/forgot",
response_class=HTMLResponse,
summary="Forgot-password request page",
include_in_schema=rs.config.enable_password_reset,
)
async def forgot_page(_request: Request) -> HTMLResponse:
return _render("auth/forgot.html", page="forgot")
@router.get(
"/reset",
response_class=HTMLResponse,
summary="Set a new password (token comes from query string)",
include_in_schema=rs.config.enable_password_reset,
)
async def reset_page(_request: Request) -> HTMLResponse:
return _render("auth/reset.html", page="reset")
@router.get(
"/verify",
response_class=HTMLResponse,
summary="Auto-confirms a verification token from the query string",
)
async def verify_page(_request: Request) -> HTMLResponse:
return _render("auth/verify.html", page="verify")
@router.get(
"/confirm-email-change",
response_class=HTMLResponse,
summary="Auto-confirms an email-change token from the query string",
)
async def confirm_email_change_page(_request: Request) -> HTMLResponse:
return _render(
"auth/email_change_confirm.html",
page="confirm-email-change",
)
@router.get(
"/me",
response_class=HTMLResponse,
summary="Authenticated account dashboard (client-side gate)",
)
async def me_page(_request: Request) -> HTMLResponse:
return _render("auth/me.html", page="me")
@router.get(
"/mfa-confirm",
response_class=HTMLResponse,
summary="Second step of an MFA-required sign-in",
include_in_schema=rs.config.enable_sms_2fa,
)
async def mfa_confirm_page(_request: Request) -> HTMLResponse:
return _render("auth/mfa_confirm.html", page="mfa-confirm")
@router.get(
"/oauth-complete",
response_class=HTMLResponse,
summary="Token-handoff landing page for OAuth callbacks",
include_in_schema=rs.config.enable_oauth,
)
async def oauth_complete_page(_request: Request) -> HTMLResponse:
return _render("auth/oauth_complete.html", page="oauth-complete")
return router
def _base_context(rs: RegStack, *, page: str) -> dict[str, object]:
return {
"page": page,
"app_name": rs.config.app_name,
"brand_logo_url": rs.config.brand_logo_url,
"brand_tagline": rs.config.brand_tagline,
"api_prefix": rs.config.api_prefix.rstrip("/"),
"ui_prefix": rs.config.ui_prefix.rstrip("/"),
"static_prefix": rs.config.static_prefix.rstrip("/"),
"theme_css_url": rs.config.theme_css_url,
"allow_registration": rs.config.allow_registration,
"enable_password_reset": rs.config.enable_password_reset,
"enable_account_deletion": rs.config.enable_account_deletion,
"enable_sms_2fa": rs.config.enable_sms_2fa,
"enable_oauth": rs.config.enable_oauth,
"oauth_providers": list(rs.oauth.names()),
}
__all__ = ["PAGE_NAMES", "build_ui_environment", "build_ui_router", "default_static_dir"]