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.
- regstack/__init__.py +5 -0
- regstack/app.py +150 -0
- regstack/auth/__init__.py +21 -0
- regstack/auth/clock.py +29 -0
- regstack/auth/dependencies.py +102 -0
- regstack/auth/jwt.py +145 -0
- regstack/auth/lockout.py +59 -0
- regstack/auth/mfa.py +29 -0
- regstack/auth/password.py +20 -0
- regstack/auth/tokens.py +19 -0
- regstack/cli/__init__.py +0 -0
- regstack/cli/__main__.py +27 -0
- regstack/cli/_runtime.py +39 -0
- regstack/cli/admin.py +45 -0
- regstack/cli/doctor.py +186 -0
- regstack/cli/init.py +236 -0
- regstack/config/__init__.py +4 -0
- regstack/config/loader.py +114 -0
- regstack/config/schema.py +148 -0
- regstack/config/secrets.py +22 -0
- regstack/db/__init__.py +17 -0
- regstack/db/client.py +26 -0
- regstack/db/indexes.py +70 -0
- regstack/db/repositories/__init__.py +0 -0
- regstack/db/repositories/blacklist_repo.py +28 -0
- regstack/db/repositories/login_attempt_repo.py +27 -0
- regstack/db/repositories/mfa_code_repo.py +99 -0
- regstack/db/repositories/pending_repo.py +76 -0
- regstack/db/repositories/user_repo.py +169 -0
- regstack/email/__init__.py +12 -0
- regstack/email/base.py +23 -0
- regstack/email/composer.py +142 -0
- regstack/email/console.py +28 -0
- regstack/email/factory.py +23 -0
- regstack/email/ses.py +47 -0
- regstack/email/smtp.py +46 -0
- regstack/email/templates/email_change.html +15 -0
- regstack/email/templates/email_change.subject.txt +1 -0
- regstack/email/templates/email_change.txt +7 -0
- regstack/email/templates/password_reset.html +15 -0
- regstack/email/templates/password_reset.subject.txt +1 -0
- regstack/email/templates/password_reset.txt +7 -0
- regstack/email/templates/sms_login_mfa.txt +1 -0
- regstack/email/templates/sms_phone_setup.txt +1 -0
- regstack/email/templates/verification.html +15 -0
- regstack/email/templates/verification.subject.txt +1 -0
- regstack/email/templates/verification.txt +7 -0
- regstack/hooks/__init__.py +3 -0
- regstack/hooks/events.py +59 -0
- regstack/models/__init__.py +15 -0
- regstack/models/_objectid.py +30 -0
- regstack/models/login_attempt.py +31 -0
- regstack/models/mfa_code.py +40 -0
- regstack/models/pending_registration.py +38 -0
- regstack/models/user.py +104 -0
- regstack/routers/__init__.py +37 -0
- regstack/routers/_schemas.py +34 -0
- regstack/routers/account.py +274 -0
- regstack/routers/admin.py +187 -0
- regstack/routers/login.py +223 -0
- regstack/routers/logout.py +39 -0
- regstack/routers/password.py +114 -0
- regstack/routers/phone.py +242 -0
- regstack/routers/register.py +99 -0
- regstack/routers/verify.py +116 -0
- regstack/sms/__init__.py +5 -0
- regstack/sms/base.py +24 -0
- regstack/sms/factory.py +23 -0
- regstack/sms/null.py +26 -0
- regstack/sms/sns.py +42 -0
- regstack/sms/twilio.py +49 -0
- regstack/ui/__init__.py +3 -0
- regstack/ui/pages.py +148 -0
- regstack/ui/static/css/core.css +204 -0
- regstack/ui/static/css/theme.css +43 -0
- regstack/ui/static/js/regstack.js +411 -0
- regstack/ui/templates/auth/email_change_confirm.html +10 -0
- regstack/ui/templates/auth/forgot.html +14 -0
- regstack/ui/templates/auth/login.html +24 -0
- regstack/ui/templates/auth/me.html +110 -0
- regstack/ui/templates/auth/mfa_confirm.html +14 -0
- regstack/ui/templates/auth/register.html +23 -0
- regstack/ui/templates/auth/reset.html +13 -0
- regstack/ui/templates/auth/verify.html +10 -0
- regstack/ui/templates/base.html +46 -0
- regstack/version.py +1 -0
- regstack-0.1.0.dist-info/METADATA +209 -0
- regstack-0.1.0.dist-info/RECORD +92 -0
- regstack-0.1.0.dist-info/WHEEL +4 -0
- regstack-0.1.0.dist-info/entry_points.txt +2 -0
- regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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"]
|
regstack/sms/__init__.py
ADDED
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: ...
|
regstack/sms/factory.py
ADDED
|
@@ -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
|
+
)
|
regstack/ui/__init__.py
ADDED
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"]
|