diff --git a/apps/api/app/api/v1/auth.py b/apps/api/app/api/v1/auth.py
index 37842a1..72f030f 100644
--- a/apps/api/app/api/v1/auth.py
+++ b/apps/api/app/api/v1/auth.py
@@ -33,13 +33,12 @@ router = APIRouter(prefix="/auth", tags=["auth"])
async def _enforce_turnstile(
- request: Request, settings: Settings, db: AsyncSession, token: str | None, client_ip: str
+ request: Request, settings: Settings, effective: Settings, token: str | None, client_ip: str
) -> None:
"""Required for register/login/request-password-reset when the caller
isn't on loopback and a secret is configured - skipped entirely
otherwise (see turnstile_service.turnstile_required). The secret may be
admin-configured (system_secret_service) rather than only .env-set."""
- effective = await system_secret_service.get_effective_settings(db, settings)
if not turnstile_required(is_localhost(request, settings), effective):
return
if not token or not await verify_turnstile(token, client_ip, effective):
@@ -55,8 +54,13 @@ async def register(
db: AsyncSession = Depends(get_db),
) -> UserResponse:
client_ip = get_client_ip(request, settings)
- await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
- user = await auth_service.register(db, settings, client_ip, payload)
+ # Effective (DB-override-aware) settings, computed once and reused for
+ # both Turnstile enforcement and the verification email - the Resend
+ # API key, like the Turnstile secret, may be admin-configured via
+ # system_secret_service rather than only .env-set.
+ effective = await system_secret_service.get_effective_settings(db, settings)
+ await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
+ user = await auth_service.register(db, effective, client_ip, payload)
return UserResponse.model_validate(user)
@@ -69,8 +73,9 @@ async def login(
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
client_ip = get_client_ip(request, settings)
- await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
- return await auth_service.login(db, settings, client_ip, payload)
+ effective = await system_secret_service.get_effective_settings(db, settings)
+ await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
+ return await auth_service.login(db, effective, client_ip, payload)
@router.post("/refresh", response_model=TokenResponse)
@@ -112,7 +117,8 @@ async def resend_verification(
db: AsyncSession = Depends(get_db),
) -> None:
client_ip = get_client_ip(request, settings)
- await auth_service.resend_verification(db, settings, client_ip, payload)
+ effective = await system_secret_service.get_effective_settings(db, settings)
+ await auth_service.resend_verification(db, effective, client_ip, payload)
@router.post("/request-password-reset", status_code=status.HTTP_204_NO_CONTENT)
@@ -123,8 +129,9 @@ async def request_password_reset(
db: AsyncSession = Depends(get_db),
) -> None:
client_ip = get_client_ip(request, settings)
- await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
- await auth_service.request_password_reset(db, settings, client_ip, payload)
+ effective = await system_secret_service.get_effective_settings(db, settings)
+ await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
+ await auth_service.request_password_reset(db, effective, client_ip, payload)
@router.post("/confirm-password-reset", status_code=status.HTTP_204_NO_CONTENT)
diff --git a/apps/api/app/models/enums.py b/apps/api/app/models/enums.py
index 18c67ca..a2eed64 100644
--- a/apps/api/app/models/enums.py
+++ b/apps/api/app/models/enums.py
@@ -174,3 +174,4 @@ class SystemSecretKey(StrEnum):
TURNSTILE_SITE_KEY = "turnstile_site_key"
TURNSTILE_SECRET = "turnstile_secret"
+ RESEND_API_KEY = "resend_api_key"
diff --git a/apps/api/app/services/system_secret_service.py b/apps/api/app/services/system_secret_service.py
index df6d219..9968866 100644
--- a/apps/api/app/services/system_secret_service.py
+++ b/apps/api/app/services/system_secret_service.py
@@ -1,10 +1,11 @@
"""Server-wide secrets an admin can configure from the Settings page
-instead of only via .env - today just the Cloudflare Turnstile site key
-and secret (app/services/turnstile_service.py). Storage is encrypted at
-rest (app/core/crypto.py). Unlike per-user API keys
-(user_api_key_service.py), there's exactly one value per key, shared by
-the whole app - visible/editable only to admins (see
-app/api/v1/system.py's require_admin gate), never per-user.
+instead of only via .env - the Cloudflare Turnstile site key and secret
+(app/services/turnstile_service.py) and the Resend API key
+(app/services/security_email_service.py). Storage is encrypted at rest
+(app/core/crypto.py). Unlike per-user API keys (user_api_key_service.py),
+there's exactly one value per key, shared by the whole app -
+visible/editable only to admins (see app/api/v1/system.py's require_admin
+gate), never per-user.
"""
from __future__ import annotations
@@ -35,6 +36,9 @@ META: dict[SystemSecretKey, SecretMeta] = {
SystemSecretKey.TURNSTILE_SECRET: SecretMeta(
label="Cloudflare Turnstile Secret Key", settings_field="turnstile_secret"
),
+ SystemSecretKey.RESEND_API_KEY: SecretMeta(
+ label="Resend API Key", settings_field="resend_api_key"
+ ),
}
diff --git a/apps/api/tests/unit/test_system_secrets.py b/apps/api/tests/unit/test_system_secrets.py
index 626ad87..0cae3b8 100644
--- a/apps/api/tests/unit/test_system_secrets.py
+++ b/apps/api/tests/unit/test_system_secrets.py
@@ -94,10 +94,14 @@ async def test_repository_upsert_then_get_then_delete(db_session):
# --- Service -------------------------------------------------------------
-async def test_list_status_shows_both_keys_unconfigured_by_default(db_session):
+async def test_list_status_shows_all_keys_unconfigured_by_default(db_session):
settings = get_settings()
statuses = await system_secret_service.list_status(db_session, settings)
- assert {s["key"] for s in statuses} == {"turnstile_site_key", "turnstile_secret"}
+ assert {s["key"] for s in statuses} == {
+ "turnstile_site_key",
+ "turnstile_secret",
+ "resend_api_key",
+ }
assert all(s["configured"] is False for s in statuses)
assert all(s["value"] is None for s in statuses)
@@ -165,6 +169,20 @@ async def test_get_effective_settings_overrides_only_the_keys_that_were_set(db_s
assert effective.turnstile_secret == "global-secret" # untouched, no override set
+async def test_admin_configured_resend_key_overrides_the_env_value(db_session):
+ settings = get_settings().model_copy(update={"resend_api_key": "global-resend-key"})
+ await system_secret_service.set_secret(
+ db_session,
+ SystemSecretKey.RESEND_API_KEY,
+ "admin-resend-key",
+ settings,
+ admin_user_id=uuid.uuid4(),
+ client_ip="10.0.0.1",
+ )
+ effective = await system_secret_service.get_effective_settings(db_session, settings)
+ assert effective.resend_api_key == "admin-resend-key"
+
+
# --- Endpoints -----------------------------------------------------------
diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx
index cd48390..038a21b 100644
--- a/apps/web/app/(app)/settings/page.tsx
+++ b/apps/web/app/(app)/settings/page.tsx
@@ -401,7 +401,8 @@ function ServerSecretsBox() {
- Server-wide configuration shared by every visitor, used for Cloudflare Turnstile CAPTCHA. + Server-wide configuration shared by every visitor: Cloudflare Turnstile CAPTCHA and the + Resend API key used for transactional security email.
{isLoading ? (