regstack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. regstack/__init__.py +5 -0
  2. regstack/app.py +150 -0
  3. regstack/auth/__init__.py +21 -0
  4. regstack/auth/clock.py +29 -0
  5. regstack/auth/dependencies.py +102 -0
  6. regstack/auth/jwt.py +145 -0
  7. regstack/auth/lockout.py +59 -0
  8. regstack/auth/mfa.py +29 -0
  9. regstack/auth/password.py +20 -0
  10. regstack/auth/tokens.py +19 -0
  11. regstack/cli/__init__.py +0 -0
  12. regstack/cli/__main__.py +27 -0
  13. regstack/cli/_runtime.py +39 -0
  14. regstack/cli/admin.py +45 -0
  15. regstack/cli/doctor.py +186 -0
  16. regstack/cli/init.py +236 -0
  17. regstack/config/__init__.py +4 -0
  18. regstack/config/loader.py +114 -0
  19. regstack/config/schema.py +148 -0
  20. regstack/config/secrets.py +22 -0
  21. regstack/db/__init__.py +17 -0
  22. regstack/db/client.py +26 -0
  23. regstack/db/indexes.py +70 -0
  24. regstack/db/repositories/__init__.py +0 -0
  25. regstack/db/repositories/blacklist_repo.py +28 -0
  26. regstack/db/repositories/login_attempt_repo.py +27 -0
  27. regstack/db/repositories/mfa_code_repo.py +99 -0
  28. regstack/db/repositories/pending_repo.py +76 -0
  29. regstack/db/repositories/user_repo.py +169 -0
  30. regstack/email/__init__.py +12 -0
  31. regstack/email/base.py +23 -0
  32. regstack/email/composer.py +142 -0
  33. regstack/email/console.py +28 -0
  34. regstack/email/factory.py +23 -0
  35. regstack/email/ses.py +47 -0
  36. regstack/email/smtp.py +46 -0
  37. regstack/email/templates/email_change.html +15 -0
  38. regstack/email/templates/email_change.subject.txt +1 -0
  39. regstack/email/templates/email_change.txt +7 -0
  40. regstack/email/templates/password_reset.html +15 -0
  41. regstack/email/templates/password_reset.subject.txt +1 -0
  42. regstack/email/templates/password_reset.txt +7 -0
  43. regstack/email/templates/sms_login_mfa.txt +1 -0
  44. regstack/email/templates/sms_phone_setup.txt +1 -0
  45. regstack/email/templates/verification.html +15 -0
  46. regstack/email/templates/verification.subject.txt +1 -0
  47. regstack/email/templates/verification.txt +7 -0
  48. regstack/hooks/__init__.py +3 -0
  49. regstack/hooks/events.py +59 -0
  50. regstack/models/__init__.py +15 -0
  51. regstack/models/_objectid.py +30 -0
  52. regstack/models/login_attempt.py +31 -0
  53. regstack/models/mfa_code.py +40 -0
  54. regstack/models/pending_registration.py +38 -0
  55. regstack/models/user.py +104 -0
  56. regstack/routers/__init__.py +37 -0
  57. regstack/routers/_schemas.py +34 -0
  58. regstack/routers/account.py +274 -0
  59. regstack/routers/admin.py +187 -0
  60. regstack/routers/login.py +223 -0
  61. regstack/routers/logout.py +39 -0
  62. regstack/routers/password.py +114 -0
  63. regstack/routers/phone.py +242 -0
  64. regstack/routers/register.py +99 -0
  65. regstack/routers/verify.py +116 -0
  66. regstack/sms/__init__.py +5 -0
  67. regstack/sms/base.py +24 -0
  68. regstack/sms/factory.py +23 -0
  69. regstack/sms/null.py +26 -0
  70. regstack/sms/sns.py +42 -0
  71. regstack/sms/twilio.py +49 -0
  72. regstack/ui/__init__.py +3 -0
  73. regstack/ui/pages.py +148 -0
  74. regstack/ui/static/css/core.css +204 -0
  75. regstack/ui/static/css/theme.css +43 -0
  76. regstack/ui/static/js/regstack.js +411 -0
  77. regstack/ui/templates/auth/email_change_confirm.html +10 -0
  78. regstack/ui/templates/auth/forgot.html +14 -0
  79. regstack/ui/templates/auth/login.html +24 -0
  80. regstack/ui/templates/auth/me.html +110 -0
  81. regstack/ui/templates/auth/mfa_confirm.html +14 -0
  82. regstack/ui/templates/auth/register.html +23 -0
  83. regstack/ui/templates/auth/reset.html +13 -0
  84. regstack/ui/templates/auth/verify.html +10 -0
  85. regstack/ui/templates/base.html +46 -0
  86. regstack/version.py +1 -0
  87. regstack-0.1.0.dist-info/METADATA +209 -0
  88. regstack-0.1.0.dist-info/RECORD +92 -0
  89. regstack-0.1.0.dist-info/WHEEL +4 -0
  90. regstack-0.1.0.dist-info/entry_points.txt +2 -0
  91. regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
  92. regstack-0.1.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+ from typing import TYPE_CHECKING
5
+
6
+ from fastapi import APIRouter, HTTPException, status
7
+
8
+ from regstack.auth.tokens import generate_verification_token
9
+ from regstack.db.repositories.pending_repo import PendingAlreadyExistsError
10
+ from regstack.db.repositories.user_repo import UserAlreadyExistsError
11
+ from regstack.models.pending_registration import PendingRegistration
12
+ from regstack.models.user import BaseUser, UserCreate, UserPublic
13
+ from regstack.routers._schemas import PendingResponse
14
+
15
+ if TYPE_CHECKING:
16
+ from regstack.app import RegStack
17
+
18
+
19
+ def build_register_router(rs: RegStack) -> APIRouter:
20
+ router = APIRouter()
21
+
22
+ @router.post(
23
+ "/register",
24
+ response_model=UserPublic | PendingResponse,
25
+ status_code=status.HTTP_201_CREATED,
26
+ summary="Register a new user (or start verification, if required)",
27
+ )
28
+ async def register(payload: UserCreate) -> UserPublic | PendingResponse:
29
+ if not rs.config.allow_registration:
30
+ raise HTTPException(
31
+ status_code=status.HTTP_403_FORBIDDEN,
32
+ detail="Registration is disabled.",
33
+ )
34
+
35
+ existing = await rs.users.get_by_email(payload.email)
36
+ if existing is not None:
37
+ raise HTTPException(
38
+ status_code=status.HTTP_409_CONFLICT,
39
+ detail="An account with that email already exists.",
40
+ )
41
+
42
+ hashed = rs.password_hasher.hash(payload.password)
43
+
44
+ if rs.config.require_verification:
45
+ return await _start_verification(rs, payload, hashed)
46
+
47
+ user = BaseUser(
48
+ email=payload.email,
49
+ hashed_password=hashed,
50
+ full_name=payload.full_name,
51
+ is_verified=True,
52
+ )
53
+ try:
54
+ user = await rs.users.create(user)
55
+ except UserAlreadyExistsError as exc:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_409_CONFLICT,
58
+ detail="An account with that email already exists.",
59
+ ) from exc
60
+
61
+ await rs.hooks.fire("user_registered", user=user)
62
+ return UserPublic.from_user(user)
63
+
64
+ return router
65
+
66
+
67
+ async def _start_verification(
68
+ rs: RegStack, payload: UserCreate, hashed_password: str
69
+ ) -> PendingResponse:
70
+ raw, token_hash_value = generate_verification_token()
71
+ ttl = rs.config.verification_token_ttl_seconds
72
+ expires_at = rs.clock.now() + timedelta(seconds=ttl)
73
+ pending = PendingRegistration(
74
+ email=payload.email,
75
+ hashed_password=hashed_password,
76
+ full_name=payload.full_name,
77
+ token_hash=token_hash_value,
78
+ expires_at=expires_at,
79
+ )
80
+ try:
81
+ # upsert lets a user re-attempt registration; the most recent token wins
82
+ # and the old verification link silently stops working.
83
+ pending = await rs.pending.upsert(pending)
84
+ except PendingAlreadyExistsError as exc: # pragma: no cover — upsert can't raise this
85
+ raise HTTPException(
86
+ status_code=status.HTTP_409_CONFLICT,
87
+ detail="A pending registration already exists for that email.",
88
+ ) from exc
89
+
90
+ base = str(rs.config.base_url).rstrip("/")
91
+ url = f"{base}/verify?token={raw}"
92
+ message = rs.mail.verification(
93
+ to=payload.email,
94
+ full_name=payload.full_name,
95
+ url=url,
96
+ )
97
+ await rs.email.send(message)
98
+ await rs.hooks.fire("verification_requested", email=payload.email, url=url)
99
+ return PendingResponse(email=payload.email, expires_at=expires_at.isoformat())
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import TYPE_CHECKING
5
+
6
+ from fastapi import APIRouter, HTTPException, status
7
+ from pydantic import BaseModel, ConfigDict, EmailStr
8
+
9
+ from regstack.auth.tokens import generate_verification_token, hash_token
10
+ from regstack.models.pending_registration import PendingRegistration
11
+ from regstack.models.user import BaseUser, UserPublic
12
+ from regstack.routers._schemas import MessageResponse
13
+
14
+ if TYPE_CHECKING:
15
+ from regstack.app import RegStack
16
+
17
+
18
+ class VerifyRequest(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+ token: str
21
+
22
+
23
+ class ResendRequest(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+ email: EmailStr
26
+
27
+
28
+ def build_verify_router(rs: RegStack) -> APIRouter:
29
+ router = APIRouter()
30
+
31
+ @router.post(
32
+ "/verify",
33
+ response_model=UserPublic,
34
+ summary="Confirm an email address from a verification link",
35
+ )
36
+ async def verify(payload: VerifyRequest) -> UserPublic:
37
+ token_hash_value = hash_token(payload.token)
38
+ pending = await rs.pending.find_by_token_hash(token_hash_value)
39
+ if pending is None:
40
+ raise HTTPException(
41
+ status_code=status.HTTP_400_BAD_REQUEST,
42
+ detail="Verification token is invalid or has expired.",
43
+ )
44
+ if pending.expires_at <= rs.clock.now():
45
+ await rs.pending.delete_by_email(pending.email)
46
+ raise HTTPException(
47
+ status_code=status.HTTP_400_BAD_REQUEST,
48
+ detail="Verification token has expired. Request a new one.",
49
+ )
50
+
51
+ user = BaseUser(
52
+ email=pending.email,
53
+ hashed_password=pending.hashed_password,
54
+ full_name=pending.full_name,
55
+ is_active=True,
56
+ is_verified=True,
57
+ )
58
+ user = await rs.users.create(user)
59
+ await rs.pending.delete_by_email(pending.email)
60
+
61
+ await rs.hooks.fire("user_verified", user=user)
62
+ return UserPublic.from_user(user)
63
+
64
+ @router.post(
65
+ "/resend-verification",
66
+ response_model=MessageResponse,
67
+ status_code=status.HTTP_202_ACCEPTED,
68
+ summary="Re-send a verification email if a pending registration exists",
69
+ )
70
+ async def resend(payload: ResendRequest) -> MessageResponse:
71
+ # Anti-enumeration: always return the same response regardless of
72
+ # whether a pending registration exists.
73
+ existing_user = await rs.users.get_by_email(payload.email)
74
+ if existing_user is not None:
75
+ return _ack()
76
+
77
+ pending = await rs.pending.find_by_email(payload.email)
78
+ if pending is None:
79
+ return _ack()
80
+
81
+ raw, token_hash_value = generate_verification_token()
82
+ ttl = rs.config.verification_token_ttl_seconds
83
+ new_pending = PendingRegistration(
84
+ id=None,
85
+ email=pending.email,
86
+ hashed_password=pending.hashed_password,
87
+ full_name=pending.full_name,
88
+ token_hash=token_hash_value,
89
+ expires_at=rs.clock.now() + timedelta(seconds=ttl),
90
+ )
91
+ new_pending.created_at = datetime.now(UTC)
92
+ await rs.pending.upsert(new_pending)
93
+
94
+ url = _verification_url(rs, raw)
95
+ message = rs.mail.verification(
96
+ to=pending.email,
97
+ full_name=pending.full_name,
98
+ url=url,
99
+ )
100
+ await rs.email.send(message)
101
+ await rs.hooks.fire("verification_requested", email=pending.email, url=url)
102
+ return _ack()
103
+
104
+ return router
105
+
106
+
107
+ def _ack() -> MessageResponse:
108
+ return MessageResponse(message="If a pending registration exists, a new email has been sent.")
109
+
110
+
111
+ def _verification_url(rs: RegStack, raw_token: str) -> str:
112
+ base = str(rs.config.base_url).rstrip("/")
113
+ return f"{base}/verify?token={raw_token}"
114
+
115
+
116
+ __all__ = ["ResendRequest", "VerifyRequest", "build_verify_router"]
@@ -0,0 +1,5 @@
1
+ from regstack.sms.base import SmsMessage, SmsService
2
+ from regstack.sms.factory import build_sms_service
3
+ from regstack.sms.null import NullSmsService
4
+
5
+ __all__ = ["NullSmsService", "SmsMessage", "SmsService", "build_sms_service"]
regstack/sms/base.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+
7
+ # E.164: leading '+', then 1-15 digits, no leading zero in the country code.
8
+ _E164 = re.compile(r"^\+[1-9]\d{1,14}$")
9
+
10
+
11
+ def is_valid_e164(phone: str) -> bool:
12
+ return bool(_E164.fullmatch(phone))
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class SmsMessage:
17
+ to: str
18
+ body: str
19
+ from_number: str | None = None
20
+
21
+
22
+ class SmsService(ABC):
23
+ @abstractmethod
24
+ async def send(self, message: SmsMessage) -> None: ...
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from regstack.sms.base import SmsService
6
+ from regstack.sms.null import NullSmsService
7
+
8
+ if TYPE_CHECKING:
9
+ from regstack.config.schema import SmsConfig
10
+
11
+
12
+ def build_sms_service(config: SmsConfig) -> SmsService:
13
+ if config.backend == "null":
14
+ return NullSmsService()
15
+ if config.backend == "sns":
16
+ from regstack.sms.sns import SnsSmsService
17
+
18
+ return SnsSmsService(config)
19
+ if config.backend == "twilio":
20
+ from regstack.sms.twilio import TwilioSmsService
21
+
22
+ return TwilioSmsService(config)
23
+ raise ValueError(f"Unknown SMS backend: {config.backend!r}")
regstack/sms/null.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from regstack.sms.base import SmsMessage, SmsService
6
+
7
+ log = logging.getLogger("regstack.sms.null")
8
+
9
+
10
+ class NullSmsService(SmsService):
11
+ """Default backend. Records messages in ``self.outbox`` so tests and dev
12
+ runs can inspect them without contacting a real SMS gateway. Logs each
13
+ send at INFO so the demo can grep the code out of stdout.
14
+ """
15
+
16
+ def __init__(self) -> None:
17
+ self.outbox: list[SmsMessage] = []
18
+
19
+ async def send(self, message: SmsMessage) -> None:
20
+ self.outbox.append(message)
21
+ log.info(
22
+ "[regstack/null-sms] To: %s | From: %s | Body: %s",
23
+ message.to,
24
+ message.from_number or "(unset)",
25
+ message.body,
26
+ )
regstack/sms/sns.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from regstack.sms.base import SmsMessage, SmsService
6
+
7
+ if TYPE_CHECKING:
8
+ from regstack.config.schema import SmsConfig
9
+
10
+
11
+ class SnsSmsService(SmsService):
12
+ """AWS SNS Publish-to-PhoneNumber backend. Requires the optional
13
+ ``sns`` extra (``pip install regstack[sns]`` → aioboto3).
14
+ """
15
+
16
+ def __init__(self, config: SmsConfig) -> None:
17
+ try:
18
+ import aioboto3 # noqa: F401
19
+ except ImportError as exc:
20
+ raise RuntimeError(
21
+ "The SNS SMS backend requires the 'sns' extra. "
22
+ "Install with `pip install regstack[sns]` or `uv sync --extra sns`."
23
+ ) from exc
24
+ self._config = config
25
+
26
+ async def send(self, message: SmsMessage) -> None:
27
+ import aioboto3
28
+
29
+ session = aioboto3.Session()
30
+ async with session.client("sns", region_name=self._config.sns_region) as client:
31
+ kwargs: dict[str, object] = {
32
+ "PhoneNumber": message.to,
33
+ "Message": message.body,
34
+ }
35
+ if message.from_number:
36
+ kwargs["MessageAttributes"] = {
37
+ "AWS.SNS.SMS.SenderID": {
38
+ "DataType": "String",
39
+ "StringValue": message.from_number,
40
+ }
41
+ }
42
+ await client.publish(**kwargs)
regstack/sms/twilio.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING
5
+
6
+ from regstack.sms.base import SmsMessage, SmsService
7
+
8
+ if TYPE_CHECKING:
9
+ from regstack.config.schema import SmsConfig
10
+
11
+
12
+ class TwilioSmsService(SmsService):
13
+ """Twilio Programmable Messaging backend. Requires the optional
14
+ ``twilio`` extra (``pip install regstack[twilio]``).
15
+
16
+ The Twilio Python SDK is sync; we hand off to a worker thread so we
17
+ don't block the event loop.
18
+ """
19
+
20
+ def __init__(self, config: SmsConfig) -> None:
21
+ try:
22
+ from twilio.rest import Client # noqa: F401
23
+ except ImportError as exc:
24
+ raise RuntimeError(
25
+ "The Twilio SMS backend requires the 'twilio' extra. "
26
+ "Install with `pip install regstack[twilio]` or `uv sync --extra twilio`."
27
+ ) from exc
28
+ if not (config.twilio_account_sid and config.twilio_auth_token):
29
+ raise ValueError("Twilio backend needs both twilio_account_sid and twilio_auth_token.")
30
+ if not (config.from_number or False):
31
+ raise ValueError("Twilio backend needs a from_number.")
32
+ self._config = config
33
+
34
+ async def send(self, message: SmsMessage) -> None:
35
+ from twilio.rest import Client
36
+
37
+ sid = self._config.twilio_account_sid
38
+ token = self._config.twilio_auth_token
39
+ assert sid and token # validated in __init__
40
+ client = Client(sid, token.get_secret_value())
41
+
42
+ from_number = message.from_number or self._config.from_number
43
+ await asyncio.to_thread(
44
+ lambda: client.messages.create(
45
+ to=message.to,
46
+ from_=from_number,
47
+ body=message.body,
48
+ )
49
+ )
@@ -0,0 +1,3 @@
1
+ from regstack.ui.pages import build_ui_environment, build_ui_router, default_static_dir
2
+
3
+ __all__ = ["build_ui_environment", "build_ui_router", "default_static_dir"]
regstack/ui/pages.py ADDED
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import resources
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ from fastapi import APIRouter, Request
8
+ from fastapi.responses import HTMLResponse
9
+ from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PackageLoader, select_autoescape
10
+
11
+ if TYPE_CHECKING:
12
+ from regstack.app import RegStack
13
+
14
+ _PACKAGE = "regstack.ui"
15
+ _TEMPLATE_DIR = "templates"
16
+ _STATIC_DIR = "static"
17
+
18
+ PAGE_NAMES = (
19
+ "login",
20
+ "register",
21
+ "verify",
22
+ "forgot",
23
+ "reset",
24
+ "me",
25
+ "confirm-email-change",
26
+ "mfa-confirm",
27
+ )
28
+
29
+
30
+ def default_static_dir() -> Path:
31
+ """Filesystem path to the bundled static assets — used by the StaticFiles
32
+ factory in :mod:`regstack.app`.
33
+ """
34
+ return Path(str(resources.files(_PACKAGE).joinpath(_STATIC_DIR)))
35
+
36
+
37
+ def build_ui_environment(host_template_dirs: list[Path] | None = None) -> Environment:
38
+ loaders = [FileSystemLoader(str(p)) for p in (host_template_dirs or [])]
39
+ loaders.append(PackageLoader(_PACKAGE, _TEMPLATE_DIR))
40
+ return Environment(
41
+ loader=ChoiceLoader(loaders),
42
+ autoescape=select_autoescape(["html"]),
43
+ trim_blocks=True,
44
+ lstrip_blocks=True,
45
+ keep_trailing_newline=True,
46
+ )
47
+
48
+
49
+ def build_ui_router(rs: RegStack) -> APIRouter:
50
+ """Server-rendered pages that pair with the JSON API.
51
+
52
+ Pages are stateless: they read API + UI prefixes from the page body and
53
+ let ``regstack.js`` drive form submissions and auth-state redirects.
54
+ No cookie-based session is established here, so this router is safe to
55
+ mount alongside the JSON API without CSRF middleware.
56
+ """
57
+ router = APIRouter()
58
+ env = rs.ui_env
59
+
60
+ def _render(template_name: str, page: str, **extra: object) -> HTMLResponse:
61
+ ctx = _base_context(rs, page=page)
62
+ ctx.update(extra)
63
+ body = env.get_template(template_name).render(ctx)
64
+ return HTMLResponse(body)
65
+
66
+ @router.get("/login", response_class=HTMLResponse, summary="Sign-in page")
67
+ async def login_page(_request: Request) -> HTMLResponse:
68
+ return _render("auth/login.html", page="login")
69
+
70
+ @router.get("/register", response_class=HTMLResponse, summary="Account creation page")
71
+ async def register_page(_request: Request) -> HTMLResponse:
72
+ return _render("auth/register.html", page="register")
73
+
74
+ @router.get(
75
+ "/forgot",
76
+ response_class=HTMLResponse,
77
+ summary="Forgot-password request page",
78
+ include_in_schema=rs.config.enable_password_reset,
79
+ )
80
+ async def forgot_page(_request: Request) -> HTMLResponse:
81
+ return _render("auth/forgot.html", page="forgot")
82
+
83
+ @router.get(
84
+ "/reset",
85
+ response_class=HTMLResponse,
86
+ summary="Set a new password (token comes from query string)",
87
+ include_in_schema=rs.config.enable_password_reset,
88
+ )
89
+ async def reset_page(_request: Request) -> HTMLResponse:
90
+ return _render("auth/reset.html", page="reset")
91
+
92
+ @router.get(
93
+ "/verify",
94
+ response_class=HTMLResponse,
95
+ summary="Auto-confirms a verification token from the query string",
96
+ )
97
+ async def verify_page(_request: Request) -> HTMLResponse:
98
+ return _render("auth/verify.html", page="verify")
99
+
100
+ @router.get(
101
+ "/confirm-email-change",
102
+ response_class=HTMLResponse,
103
+ summary="Auto-confirms an email-change token from the query string",
104
+ )
105
+ async def confirm_email_change_page(_request: Request) -> HTMLResponse:
106
+ return _render(
107
+ "auth/email_change_confirm.html",
108
+ page="confirm-email-change",
109
+ )
110
+
111
+ @router.get(
112
+ "/me",
113
+ response_class=HTMLResponse,
114
+ summary="Authenticated account dashboard (client-side gate)",
115
+ )
116
+ async def me_page(_request: Request) -> HTMLResponse:
117
+ return _render("auth/me.html", page="me")
118
+
119
+ @router.get(
120
+ "/mfa-confirm",
121
+ response_class=HTMLResponse,
122
+ summary="Second step of an MFA-required sign-in",
123
+ include_in_schema=rs.config.enable_sms_2fa,
124
+ )
125
+ async def mfa_confirm_page(_request: Request) -> HTMLResponse:
126
+ return _render("auth/mfa_confirm.html", page="mfa-confirm")
127
+
128
+ return router
129
+
130
+
131
+ def _base_context(rs: RegStack, *, page: str) -> dict[str, object]:
132
+ return {
133
+ "page": page,
134
+ "app_name": rs.config.app_name,
135
+ "brand_logo_url": rs.config.brand_logo_url,
136
+ "brand_tagline": rs.config.brand_tagline,
137
+ "api_prefix": rs.config.api_prefix.rstrip("/"),
138
+ "ui_prefix": rs.config.ui_prefix.rstrip("/"),
139
+ "static_prefix": rs.config.static_prefix.rstrip("/"),
140
+ "theme_css_url": rs.config.theme_css_url,
141
+ "allow_registration": rs.config.allow_registration,
142
+ "enable_password_reset": rs.config.enable_password_reset,
143
+ "enable_account_deletion": rs.config.enable_account_deletion,
144
+ "enable_sms_2fa": rs.config.enable_sms_2fa,
145
+ }
146
+
147
+
148
+ __all__ = ["PAGE_NAMES", "build_ui_environment", "build_ui_router", "default_static_dir"]