datagrowth-common 0.3.2__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 (51) hide show
  1. datagrowth_common/__init__.py +83 -0
  2. datagrowth_common/api/__init__.py +20 -0
  3. datagrowth_common/api/errors.py +63 -0
  4. datagrowth_common/api/pagination.py +51 -0
  5. datagrowth_common/api/users.py +78 -0
  6. datagrowth_common/auth/__init__.py +17 -0
  7. datagrowth_common/auth/local.py +48 -0
  8. datagrowth_common/auth/router.py +253 -0
  9. datagrowth_common/auth/session.py +139 -0
  10. datagrowth_common/auth/session_hmac.py +190 -0
  11. datagrowth_common/auth/supabase.py +144 -0
  12. datagrowth_common/auth/supabase_admin.py +164 -0
  13. datagrowth_common/logger.py +48 -0
  14. datagrowth_common/result.py +14 -0
  15. datagrowth_common/shared_models.py +33 -0
  16. datagrowth_common/supabase.py +46 -0
  17. datagrowth_common/testing/__init__.py +12 -0
  18. datagrowth_common/testing/fixtures.py +48 -0
  19. datagrowth_common/ui/__init__.py +4 -0
  20. datagrowth_common/ui/loader.py +55 -0
  21. datagrowth_common/ui/static/css/_tailwind.source.css +41 -0
  22. datagrowth_common/ui/static/css/tailwind.compiled.css +1 -0
  23. datagrowth_common/ui/static/css/tokens.css +113 -0
  24. datagrowth_common/ui/static/js/htmx-events.js +51 -0
  25. datagrowth_common/ui/static/js/theme.js +49 -0
  26. datagrowth_common/ui/static/js/toasts.js +22 -0
  27. datagrowth_common/ui/templates/dg/admin/audit_panel.html +22 -0
  28. datagrowth_common/ui/templates/dg/admin/roles_panel.html +18 -0
  29. datagrowth_common/ui/templates/dg/admin/users_panel.html +52 -0
  30. datagrowth_common/ui/templates/dg/components/badge.html +28 -0
  31. datagrowth_common/ui/templates/dg/components/button.html +76 -0
  32. datagrowth_common/ui/templates/dg/components/card.html +33 -0
  33. datagrowth_common/ui/templates/dg/components/command_palette.html +44 -0
  34. datagrowth_common/ui/templates/dg/components/data_table.html +42 -0
  35. datagrowth_common/ui/templates/dg/components/empty_state.html +27 -0
  36. datagrowth_common/ui/templates/dg/components/form_field.html +99 -0
  37. datagrowth_common/ui/templates/dg/components/icon.html +90 -0
  38. datagrowth_common/ui/templates/dg/components/modal.html +39 -0
  39. datagrowth_common/ui/templates/dg/components/page_header.html +35 -0
  40. datagrowth_common/ui/templates/dg/components/paginated_table.html +61 -0
  41. datagrowth_common/ui/templates/dg/components/sidebar_link.html +21 -0
  42. datagrowth_common/ui/templates/dg/components/skeleton.html +10 -0
  43. datagrowth_common/ui/templates/dg/components/theme_toggle.html +27 -0
  44. datagrowth_common/ui/templates/dg/components/toast.html +31 -0
  45. datagrowth_common/ui/templates/dg/layouts/empty.html +25 -0
  46. datagrowth_common/ui/templates/dg/layouts/sidebar_shell.html +86 -0
  47. datagrowth_common/updater/__init__.py +0 -0
  48. datagrowth_common/updater/checker.py +48 -0
  49. datagrowth_common-0.3.2.dist-info/METADATA +306 -0
  50. datagrowth_common-0.3.2.dist-info/RECORD +51 -0
  51. datagrowth_common-0.3.2.dist-info/WHEEL +4 -0
@@ -0,0 +1,83 @@
1
+ """datagrowth-common — utilidades compartidas entre apps Datagrowth.
2
+
3
+ v0.3.1 (mayo 2026):
4
+ - Nuevo subpaquete `datagrowth_common.ui`: Jinja2 partials (`dg/...`),
5
+ tokens CSS con tema light/dark, Tailwind precompilado y JS de theme/
6
+ toasts/htmx-events. Helper `register_ui(app, jinja_env, mount_path)`
7
+ para inyectar todo en una FastAPI host app.
8
+
9
+ v0.3.0 (mayo 2026, BREAKING):
10
+ - Eliminados `datagrowth_common.authentik` y `datagrowth_common.auth_fastapi`
11
+ (forward-auth basado en Authentik). Importarlos lanza `ImportError`.
12
+ - `datagrowth_common.auth.local` renombrado a `datagrowth_common.auth.session_hmac`.
13
+ Se eliminó toda la integración con Authentik OIDC (password_login, refresh,
14
+ userinfo, start_oidc, oidc_callback, register_local, magic_link_request,
15
+ password_reset, TokenPair, OidcStart). Solo se conserva la cookie HMAC.
16
+ `auth.local` queda como alias deprecated (DeprecationWarning); eliminado en v0.4.0.
17
+ - Modelo único de auth: Supabase Auth (cookie JWT en `auth.session` +
18
+ endpoints en `auth.router` + verify en `auth.supabase`).
19
+
20
+ v0.2.2 (anterior):
21
+ - `auth.supabase_admin` añadidos `create_user_with_password` y
22
+ `send_password_reset` para flujos de gestión de usuarios end-to-end.
23
+
24
+ Ver CHANGELOG.md para la guía de migración completa.
25
+ """
26
+ from .auth.supabase import (
27
+ SupabaseUser,
28
+ SupabaseUserDep,
29
+ require_role,
30
+ require_supabase_jwt,
31
+ verify_supabase_jwt,
32
+ )
33
+ from .auth.session import (
34
+ ACCESS_COOKIE,
35
+ REFRESH_COOKIE,
36
+ SessionTokens,
37
+ SessionUserDep,
38
+ clear_session,
39
+ get_session_tokens,
40
+ refresh_session_tokens,
41
+ require_session,
42
+ set_session,
43
+ )
44
+ from .auth.router import auth_router, make_auth_router
45
+ from .api.users import users_router
46
+ from .result import Result, ok, err
47
+ from .logger import get_logger, setup_logging
48
+ from .ui import UI_VERSION, register_ui
49
+
50
+ __version__ = "0.3.2"
51
+
52
+ __all__ = [
53
+ # supabase auth (JWT)
54
+ "SupabaseUser",
55
+ "SupabaseUserDep",
56
+ "require_role",
57
+ "require_supabase_jwt",
58
+ "verify_supabase_jwt",
59
+ "users_router",
60
+ # session (cookie httpOnly server-rendered)
61
+ "ACCESS_COOKIE",
62
+ "REFRESH_COOKIE",
63
+ "SessionTokens",
64
+ "SessionUserDep",
65
+ "set_session",
66
+ "clear_session",
67
+ "get_session_tokens",
68
+ "refresh_session_tokens",
69
+ "require_session",
70
+ # auth router (login + OAuth + refresh + logout)
71
+ "auth_router",
72
+ "make_auth_router",
73
+ # core
74
+ "Result",
75
+ "ok",
76
+ "err",
77
+ "get_logger",
78
+ "setup_logging",
79
+ # ui (datagrowth_common.ui)
80
+ "register_ui",
81
+ "UI_VERSION",
82
+ "__version__",
83
+ ]
@@ -0,0 +1,20 @@
1
+ """API helpers compartidos: respuesta estandar y paginacion.
2
+
3
+ Para `users_router` (CRUD sobre Supabase Admin API), importar
4
+ explicitamente: `from datagrowth_common.api.users import users_router`.
5
+ Se evita re-exportarlo aqui para no forzar el import de
6
+ `auth.supabase` cuando un consumidor solo necesita helpers genericos.
7
+ """
8
+ from .errors import ApiResponse, ErrorBody, ErrorCode, err_response, ok_response
9
+ from .pagination import PageMeta, Paginated, paginate_params
10
+
11
+ __all__ = [
12
+ "ApiResponse",
13
+ "ErrorBody",
14
+ "ErrorCode",
15
+ "err_response",
16
+ "ok_response",
17
+ "PageMeta",
18
+ "Paginated",
19
+ "paginate_params",
20
+ ]
@@ -0,0 +1,63 @@
1
+ """Respuesta estandar `ApiResponse` y catalogo de errores.
2
+
3
+ Codigo y mensaje van siempre en `error`. Los handlers nunca lanzan
4
+ excepciones a traves de la frontera HTTP: devuelven `ApiResponse` con
5
+ `error` poblado o `data` poblado, exclusivo.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+ from typing import Any, Generic, TypeVar
11
+
12
+ from pydantic import BaseModel, Field, model_validator
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class ErrorCode(str, Enum):
18
+ NOT_FOUND = "NOT_FOUND"
19
+ VALIDATION_ERROR = "VALIDATION_ERROR"
20
+ UNAUTHORIZED = "UNAUTHORIZED"
21
+ FORBIDDEN = "FORBIDDEN"
22
+ RATE_LIMITED = "RATE_LIMITED"
23
+ CONFLICT = "CONFLICT"
24
+ INTERNAL_ERROR = "INTERNAL_ERROR"
25
+
26
+
27
+ class ErrorBody(BaseModel):
28
+ code: ErrorCode
29
+ message: str
30
+ details: dict[str, Any] | None = None
31
+
32
+
33
+ class ApiResponse(BaseModel, Generic[T]):
34
+ data: T | None = None
35
+ error: ErrorBody | None = None
36
+ meta: dict[str, Any] | None = Field(default=None)
37
+
38
+ @model_validator(mode="after")
39
+ def _xor_data_error(self) -> "ApiResponse[T]":
40
+ if (self.data is None) == (self.error is None):
41
+ raise ValueError("ApiResponse requires exactly one of data|error")
42
+ return self
43
+
44
+
45
+ def ok_response(data: T, meta: dict[str, Any] | None = None) -> ApiResponse[T]:
46
+ return ApiResponse[T](data=data, meta=meta)
47
+
48
+
49
+ def err_response(
50
+ code: ErrorCode, message: str, details: dict[str, Any] | None = None,
51
+ ) -> ApiResponse[Any]:
52
+ return ApiResponse[Any](error=ErrorBody(code=code, message=message, details=details))
53
+
54
+
55
+ HTTP_STATUS_FOR_CODE: dict[ErrorCode, int] = {
56
+ ErrorCode.NOT_FOUND: 404,
57
+ ErrorCode.VALIDATION_ERROR: 422,
58
+ ErrorCode.UNAUTHORIZED: 401,
59
+ ErrorCode.FORBIDDEN: 403,
60
+ ErrorCode.RATE_LIMITED: 429,
61
+ ErrorCode.CONFLICT: 409,
62
+ ErrorCode.INTERNAL_ERROR: 500,
63
+ }
@@ -0,0 +1,51 @@
1
+ """Paginacion estandar `limit/offset`.
2
+
3
+ Las queries siempre van con LIMIT (regla de rendimiento §3 del proyecto).
4
+ Este modulo expone `paginate_params` para validar/clip de query params y
5
+ `Paginated[T]` como envolvente de respuesta.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Generic, TypeVar
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ T = TypeVar("T")
14
+
15
+ DEFAULT_LIMIT = 20
16
+ MAX_LIMIT = 100
17
+
18
+
19
+ class PageMeta(BaseModel):
20
+ total: int = Field(ge=0)
21
+ limit: int = Field(ge=1, le=MAX_LIMIT)
22
+ offset: int = Field(ge=0)
23
+ has_more: bool
24
+
25
+
26
+ class Paginated(BaseModel, Generic[T]):
27
+ data: list[T]
28
+ meta: PageMeta
29
+
30
+
31
+ def paginate_params(
32
+ limit: int | None = None,
33
+ offset: int | None = None,
34
+ ) -> tuple[int, int]:
35
+ """Valida y clipea limit/offset. Devuelve `(limit_safe, offset_safe)`.
36
+
37
+ `None` se trata como "no especificado" → default. Cualquier entero
38
+ fuera de rango se clipea (limit a [1, MAX_LIMIT], offset a [0, ∞)).
39
+ """
40
+ raw_limit = DEFAULT_LIMIT if limit is None else int(limit)
41
+ raw_offset = 0 if offset is None else int(offset)
42
+ safe_limit = max(1, min(raw_limit, MAX_LIMIT))
43
+ safe_offset = max(0, raw_offset)
44
+ return safe_limit, safe_offset
45
+
46
+
47
+ def build_meta(total: int, limit: int, offset: int) -> PageMeta:
48
+ return PageMeta(
49
+ total=total, limit=limit, offset=offset,
50
+ has_more=offset + limit < total,
51
+ )
@@ -0,0 +1,78 @@
1
+ """Router CRUD de usuarios sobre Supabase Admin API.
2
+
3
+ Para usar: `app.include_router(users_router)`. La app debe definir las
4
+ variables de entorno `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` y
5
+ `SUPABASE_JWT_SECRET`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from fastapi import APIRouter, Depends, Query
10
+ from pydantic import BaseModel, EmailStr, Field
11
+
12
+ from ..auth.supabase import (
13
+ SupabaseUser,
14
+ SupabaseUserDep,
15
+ require_role,
16
+ )
17
+ from ..auth import supabase_admin
18
+ from ..api.pagination import Paginated, paginate_params
19
+ from .errors import (
20
+ ApiResponse,
21
+ ErrorCode,
22
+ err_response,
23
+ ok_response,
24
+ )
25
+
26
+
27
+ users_router = APIRouter(prefix="/api/users", tags=["users"])
28
+
29
+
30
+ class InviteBody(BaseModel):
31
+ email: EmailStr = Field(..., max_length=254)
32
+ role: str = Field(default="user", min_length=1, max_length=50)
33
+ redirect_to: str | None = Field(default=None, max_length=2048)
34
+
35
+
36
+ _AdminUser = Depends(require_role("admin"))
37
+
38
+
39
+ @users_router.get("/me", response_model=ApiResponse[SupabaseUser])
40
+ async def get_me(user: SupabaseUserDep) -> ApiResponse[SupabaseUser]:
41
+ return ok_response(user)
42
+
43
+
44
+ @users_router.get("", response_model=ApiResponse[Paginated[SupabaseUser]])
45
+ async def list_users(
46
+ _admin: SupabaseUser = _AdminUser,
47
+ limit: int | None = Query(default=None, ge=1, le=100),
48
+ offset: int | None = Query(default=None, ge=0),
49
+ ) -> ApiResponse[Paginated[SupabaseUser]]:
50
+ safe_limit, safe_offset = paginate_params(limit, offset)
51
+ page, exc = await supabase_admin.list_users(limit=safe_limit, offset=safe_offset)
52
+ if exc is not None or page is None:
53
+ return err_response(ErrorCode.INTERNAL_ERROR, "list-users-failed")
54
+ return ok_response(page)
55
+
56
+
57
+ @users_router.post(
58
+ "/invite", response_model=ApiResponse[SupabaseUser], status_code=201,
59
+ )
60
+ async def invite_user(
61
+ body: InviteBody, _admin: SupabaseUser = _AdminUser,
62
+ ) -> ApiResponse[SupabaseUser]:
63
+ user, exc = await supabase_admin.invite_user_by_email(
64
+ body.email, role=body.role, redirect_to=body.redirect_to,
65
+ )
66
+ if exc is not None or user is None:
67
+ return err_response(ErrorCode.INTERNAL_ERROR, "invite-failed")
68
+ return ok_response(user)
69
+
70
+
71
+ @users_router.delete("/{user_id}", response_model=ApiResponse[dict])
72
+ async def delete_user(
73
+ user_id: str, _admin: SupabaseUser = _AdminUser,
74
+ ) -> ApiResponse[dict]:
75
+ _, exc = await supabase_admin.delete_user(user_id)
76
+ if exc is not None:
77
+ return err_response(ErrorCode.INTERNAL_ERROR, "delete-failed")
78
+ return ok_response({"deleted": user_id})
@@ -0,0 +1,17 @@
1
+ """Helpers de autenticacion para apps Datagrowth (Supabase Auth).
2
+
3
+ - `supabase`: validacion de JWT firmado por Supabase Auth.
4
+ - `supabase_admin`: wrappers async sobre Supabase Admin API (list/invite/delete users).
5
+ - `session`: cookie httpOnly server-rendered (`set_session`, `clear_session`,
6
+ `require_session`, `refresh_session_tokens`). Usar en apps Jinja+HTMX.
7
+ - `router`: `make_auth_router()` / `auth_router` con endpoints de login propio +
8
+ OAuth + refresh + logout, reutilizable entre el backend interno y las apps cliente.
9
+ - `session_hmac`: cookie de sesion firmada con HMAC propio. Solo para apps
10
+ internas que NO usan Supabase Auth (fallback). En v0.2.x se llamaba `local`.
11
+
12
+ `local` se mantiene como alias deprecated de `session_hmac` (DeprecationWarning).
13
+ Eliminado en v0.4.0.
14
+ """
15
+ from . import router, session, session_hmac, supabase, supabase_admin
16
+
17
+ __all__ = ["router", "session", "session_hmac", "supabase", "supabase_admin"]
@@ -0,0 +1,48 @@
1
+ """DEPRECATED alias: `datagrowth_common.auth.local` → `auth.session_hmac`.
2
+
3
+ En v0.3.0 el archivo se renombró a `session_hmac.py` y se eliminó toda la
4
+ integración con Authentik (OIDC, magic link, password reset, register).
5
+ Solo se conserva la cookie HMAC de sesión propia.
6
+
7
+ Importar desde aquí emite `DeprecationWarning`. Se eliminará en v0.4.0.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+
13
+ warnings.warn(
14
+ "datagrowth_common.auth.local is deprecated since v0.3.0; "
15
+ "use datagrowth_common.auth.session_hmac instead. "
16
+ "Authentik OIDC helpers (password_login, refresh_token, userinfo, "
17
+ "start_oidc, oidc_callback, register_local, magic_link_request, "
18
+ "password_reset, TokenPair, OidcStart) were removed; use "
19
+ "datagrowth_common.auth.router + datagrowth_common.auth.supabase.",
20
+ DeprecationWarning,
21
+ stacklevel=2,
22
+ )
23
+
24
+ from .session_hmac import ( # noqa: E402,F401
25
+ SessionCookie,
26
+ SessionDep,
27
+ SessionUser,
28
+ clear_session_cookie,
29
+ decode_session,
30
+ encode_session,
31
+ make_session,
32
+ require_group,
33
+ require_session,
34
+ set_session_cookie,
35
+ )
36
+
37
+ __all__ = [
38
+ "SessionCookie",
39
+ "SessionDep",
40
+ "SessionUser",
41
+ "clear_session_cookie",
42
+ "decode_session",
43
+ "encode_session",
44
+ "make_session",
45
+ "require_group",
46
+ "require_session",
47
+ "set_session_cookie",
48
+ ]
@@ -0,0 +1,253 @@
1
+ """Router de autenticacion reutilizable: login propio + OAuth + refresh + logout.
2
+
3
+ La pagina HTML de `/login` la sirve la propia app (cada una con su branding);
4
+ este router solo expone los endpoints API y el OAuth callback.
5
+
6
+ Uso (en `src/main.py` de la app):
7
+
8
+ from fastapi.templating import Jinja2Templates
9
+ from datagrowth_common.auth.router import make_auth_router
10
+
11
+ auth_router = make_auth_router(post_login_redirect="/")
12
+ app.include_router(auth_router)
13
+
14
+ Endpoints expuestos:
15
+ POST /auth/password -> form email+password, set cookie
16
+ POST /auth/register -> sign-up (si AUTH_REGISTRATION=open)
17
+ POST /auth/magic-link/request -> sign_in_with_otp(email)
18
+ POST /auth/password/reset -> reset_password_for_email
19
+ GET /auth/oauth/{provider}/start -> redirige a Supabase OAuth
20
+ GET /auth/oauth/{provider}/callback -> exchange code -> set cookie
21
+ POST /auth/refresh -> refresca tokens si la cookie caduco
22
+ POST /logout -> borra cookie y redirige
23
+ GET /api/me -> SupabaseUser actual (json)
24
+
25
+ Requiere las envvars:
26
+ SUPABASE_URL, SUPABASE_KEY, SUPABASE_JWT_SECRET (+ optional AUTH_*).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from typing import Annotated, Any
32
+
33
+ from fastapi import APIRouter, Form, Query, Request, Response, status
34
+ from fastapi.responses import RedirectResponse
35
+ from pydantic import BaseModel, EmailStr, Field
36
+
37
+ from ..api.errors import (
38
+ ApiResponse,
39
+ ErrorCode,
40
+ err_response,
41
+ ok_response,
42
+ )
43
+ from .session import (
44
+ SessionUserDep,
45
+ clear_session,
46
+ get_session_tokens,
47
+ refresh_session_tokens,
48
+ set_session,
49
+ )
50
+ from .supabase import SupabaseUser
51
+
52
+
53
+ def _registration_open() -> bool:
54
+ return os.getenv("AUTH_REGISTRATION", "invite_only") == "open"
55
+
56
+
57
+ def _magic_link_enabled() -> bool:
58
+ return os.getenv("AUTH_MAGIC_LINK", "false").lower() == "true"
59
+
60
+
61
+ def _redirect_base() -> str:
62
+ """URL publica de la app, para construir redirect_to del OAuth callback."""
63
+ return os.getenv("DG_APP_URL", "").rstrip("/")
64
+
65
+
66
+ def _session_from_supabase(result: Any) -> dict[str, Any]:
67
+ session = getattr(result, "session", None)
68
+ if session is None:
69
+ return {}
70
+ return {
71
+ "access_token": getattr(session, "access_token", ""),
72
+ "refresh_token": getattr(session, "refresh_token", ""),
73
+ }
74
+
75
+
76
+ class _RefreshBody(BaseModel):
77
+ pass # refresh lee de cookie, no de body
78
+
79
+
80
+ def make_auth_router(
81
+ *,
82
+ prefix: str = "",
83
+ post_login_redirect: str = "/",
84
+ post_logout_redirect: str = "/login",
85
+ oauth_callback_path_prefix: str = "/auth/oauth",
86
+ ) -> APIRouter:
87
+ """Construye el APIRouter de auth con redirects configurables.
88
+
89
+ El router NO sirve `/login` HTML — eso vive en la app (template propio).
90
+ """
91
+ router = APIRouter(prefix=prefix, tags=["auth"])
92
+
93
+ # ── Password (Supabase email/password) ─────────────────────────────
94
+
95
+ @router.post("/auth/password", response_class=RedirectResponse)
96
+ async def auth_password(
97
+ email: Annotated[str, Form(min_length=3, max_length=254)],
98
+ password: Annotated[str, Form(min_length=8, max_length=200)],
99
+ ) -> RedirectResponse:
100
+ try:
101
+ from ..supabase import get_supabase_client
102
+ client = get_supabase_client()
103
+ result = client.auth.sign_in_with_password(
104
+ {"email": email, "password": password},
105
+ )
106
+ except Exception:
107
+ return RedirectResponse("/login?error=invalid-credentials", status_code=303)
108
+ tokens = _session_from_supabase(result)
109
+ if not tokens.get("access_token"):
110
+ return RedirectResponse("/login?error=invalid-credentials", status_code=303)
111
+ redir = RedirectResponse(post_login_redirect, status_code=303)
112
+ set_session(
113
+ redir,
114
+ access_token=tokens["access_token"],
115
+ refresh_token=tokens.get("refresh_token"),
116
+ )
117
+ return redir
118
+
119
+ # ── Register (sign-up) ────────────────────────────────────────────
120
+
121
+ @router.post("/auth/register", response_model=ApiResponse)
122
+ async def auth_register(
123
+ email: Annotated[str, Form(min_length=3, max_length=254)],
124
+ password: Annotated[str, Form(min_length=8, max_length=200)],
125
+ name: Annotated[str, Form(min_length=1, max_length=120)] = "",
126
+ ) -> ApiResponse:
127
+ if not _registration_open():
128
+ return err_response(ErrorCode.FORBIDDEN, "registration-closed")
129
+ try:
130
+ from ..supabase import get_supabase_client
131
+ client = get_supabase_client()
132
+ client.auth.sign_up({
133
+ "email": email, "password": password,
134
+ "options": {"data": {"name": name}} if name else {},
135
+ })
136
+ except Exception:
137
+ return err_response(ErrorCode.CONFLICT, "register-failed")
138
+ return ok_response({"created": True, "email": email})
139
+
140
+ # ── Magic link (OTP via email) ────────────────────────────────────
141
+
142
+ @router.post("/auth/magic-link/request", response_model=ApiResponse)
143
+ async def auth_magic_link(
144
+ email: Annotated[str, Form(min_length=3, max_length=254)],
145
+ ) -> ApiResponse:
146
+ if not _magic_link_enabled():
147
+ return err_response(ErrorCode.FORBIDDEN, "magic-link-disabled")
148
+ try:
149
+ from ..supabase import get_supabase_client
150
+ client = get_supabase_client()
151
+ client.auth.sign_in_with_otp({"email": email})
152
+ except Exception:
153
+ return err_response(ErrorCode.INTERNAL_ERROR, "magic-link-failed")
154
+ return ok_response({"sent": True})
155
+
156
+ # ── Password reset ────────────────────────────────────────────────
157
+
158
+ @router.post("/auth/password/reset", response_model=ApiResponse)
159
+ async def auth_password_reset(
160
+ email: Annotated[str, Form(min_length=3, max_length=254)],
161
+ ) -> ApiResponse:
162
+ try:
163
+ from ..supabase import get_supabase_client
164
+ client = get_supabase_client()
165
+ client.auth.reset_password_for_email(email)
166
+ except Exception:
167
+ return err_response(ErrorCode.INTERNAL_ERROR, "reset-failed")
168
+ return ok_response({"sent": True})
169
+
170
+ # ── OAuth (Microsoft/Google/etc. via Supabase) ───────────────────
171
+
172
+ @router.get(f"{oauth_callback_path_prefix}/{{provider}}/start")
173
+ async def auth_oauth_start(
174
+ provider: str, request: Request,
175
+ ) -> RedirectResponse:
176
+ """Redirige al consent de Supabase para el provider OAuth indicado."""
177
+ base = _redirect_base() or str(request.base_url).rstrip("/")
178
+ callback = f"{base}{oauth_callback_path_prefix}/{provider}/callback"
179
+ try:
180
+ from ..supabase import get_supabase_client
181
+ client = get_supabase_client()
182
+ result = client.auth.sign_in_with_oauth({
183
+ "provider": provider,
184
+ "options": {"redirect_to": callback},
185
+ })
186
+ url = getattr(result, "url", None)
187
+ except Exception:
188
+ return RedirectResponse(url="/login?error=oauth-start", status_code=303)
189
+ if not url:
190
+ return RedirectResponse(url="/login?error=oauth-start", status_code=303)
191
+ return RedirectResponse(url=str(url), status_code=303)
192
+
193
+ @router.get(f"{oauth_callback_path_prefix}/{{provider}}/callback")
194
+ async def auth_oauth_callback(
195
+ provider: str,
196
+ code: Annotated[str, Query(min_length=1, max_length=2048)],
197
+ ) -> RedirectResponse:
198
+ """Canjea el `?code=` por session JWT y guarda cookies."""
199
+ try:
200
+ from ..supabase import get_supabase_client
201
+ client = get_supabase_client()
202
+ result = client.auth.exchange_code_for_session({"auth_code": code})
203
+ except Exception:
204
+ return RedirectResponse(url="/login?error=oauth-exchange", status_code=303)
205
+ tokens = _session_from_supabase(result)
206
+ if not tokens.get("access_token"):
207
+ return RedirectResponse(url="/login?error=oauth-exchange", status_code=303)
208
+ redir = RedirectResponse(post_login_redirect, status_code=303)
209
+ set_session(
210
+ redir,
211
+ access_token=tokens["access_token"],
212
+ refresh_token=tokens.get("refresh_token"),
213
+ )
214
+ return redir
215
+
216
+ # ── Refresh ───────────────────────────────────────────────────────
217
+
218
+ @router.post("/auth/refresh", response_model=ApiResponse)
219
+ async def auth_refresh(
220
+ request: Request, response: Response,
221
+ ) -> ApiResponse:
222
+ tokens = get_session_tokens(request)
223
+ if tokens is None or not tokens.refresh_token:
224
+ return err_response(ErrorCode.UNAUTHORIZED, "no-refresh-token")
225
+ new_tokens, exc = await refresh_session_tokens(tokens.refresh_token)
226
+ if exc is not None or new_tokens is None:
227
+ return err_response(ErrorCode.UNAUTHORIZED, "refresh-failed")
228
+ set_session(
229
+ response,
230
+ access_token=new_tokens["access_token"],
231
+ refresh_token=new_tokens.get("refresh_token"),
232
+ )
233
+ return ok_response({"refreshed": True})
234
+
235
+ # ── Logout ────────────────────────────────────────────────────────
236
+
237
+ @router.post("/logout", response_class=RedirectResponse)
238
+ async def logout() -> RedirectResponse:
239
+ redir = RedirectResponse(post_logout_redirect, status_code=303)
240
+ clear_session(redir)
241
+ return redir
242
+
243
+ # ── Identity ──────────────────────────────────────────────────────
244
+
245
+ @router.get("/api/me", response_model=ApiResponse[SupabaseUser])
246
+ async def me(user: SessionUserDep) -> ApiResponse[SupabaseUser]:
247
+ return ok_response(user)
248
+
249
+ return router
250
+
251
+
252
+ # Router por defecto con redirects estandar (post_login → "/").
253
+ auth_router = make_auth_router()