django-admin-react 0.1.0a1__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 (33) hide show
  1. LICENSE +21 -0
  2. django_admin_react/README.md +57 -0
  3. django_admin_react/__init__.py +10 -0
  4. django_admin_react/api/README.md +31 -0
  5. django_admin_react/api/__init__.py +5 -0
  6. django_admin_react/api/permissions.py +80 -0
  7. django_admin_react/api/registry.py +200 -0
  8. django_admin_react/api/serializers.py +183 -0
  9. django_admin_react/api/urls.py +84 -0
  10. django_admin_react/api/views/README.md +21 -0
  11. django_admin_react/api/views/__init__.py +6 -0
  12. django_admin_react/api/views/create.py +141 -0
  13. django_admin_react/api/views/destroy.py +89 -0
  14. django_admin_react/api/views/detail.py +283 -0
  15. django_admin_react/api/views/list.py +271 -0
  16. django_admin_react/api/views/registry.py +51 -0
  17. django_admin_react/api/views/update.py +121 -0
  18. django_admin_react/api/writes.py +325 -0
  19. django_admin_react/apps.py +27 -0
  20. django_admin_react/conf.py +77 -0
  21. django_admin_react/static/admin_react/.vite/manifest.json +11 -0
  22. django_admin_react/static/admin_react/assets/index-CKxeWYBA.css +1 -0
  23. django_admin_react/static/admin_react/assets/index-itk7hrnq.js +68 -0
  24. django_admin_react/static/admin_react/assets/index-itk7hrnq.js.map +1 -0
  25. django_admin_react/static/admin_react/index.html +13 -0
  26. django_admin_react/templates/admin_react/README.md +10 -0
  27. django_admin_react/templates/admin_react/index.html +33 -0
  28. django_admin_react/urls.py +39 -0
  29. django_admin_react/views.py +136 -0
  30. django_admin_react-0.1.0a1.dist-info/LICENSE +21 -0
  31. django_admin_react-0.1.0a1.dist-info/METADATA +237 -0
  32. django_admin_react-0.1.0a1.dist-info/RECORD +33 -0
  33. django_admin_react-0.1.0a1.dist-info/WHEEL +4 -0
@@ -0,0 +1,325 @@
1
+ """Shared helpers for the write endpoints (POST / PATCH / DELETE).
2
+
3
+ Wire contract: ``docs/api-contract.md`` §5 and §6.
4
+
5
+ Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
6
+
7
+ - Rule 6: Writes go through ``ModelAdmin.get_form()`` — never
8
+ ``setattr(obj, ...)`` directly (B-3).
9
+ - Rule 7: Deletes go through ``ModelAdmin.delete_model()`` — never
10
+ ``obj.delete()`` (B-4).
11
+ - Rule 12: ``readonly`` / ``exclude`` field names in the payload are a
12
+ 400 ``bad_request`` (S-31, B-3).
13
+ - Defense-in-depth: sensitive-name denylist is applied on top of the
14
+ admin's own ``exclude`` so a misconfigured admin still cannot leak.
15
+
16
+ Public surface — every view layer should reach for one of these first:
17
+
18
+ - :func:`bad_request` — uniform 400 envelope.
19
+ - :func:`validation_failed` — uniform 400 with per-field errors.
20
+ - :func:`not_found_response` — canonical 404 envelope.
21
+ - :func:`parse_json_body` — decode a JSON object or return 400.
22
+ - :func:`load_object_or_none` — fetch through ``get_queryset`` or
23
+ return ``None`` (caller emits 404).
24
+ - :func:`writable_field_names` — fields the API will accept on write.
25
+ - :func:`readonly_or_excluded_names`
26
+ — fields the payload may not mention.
27
+ - :func:`reject_forbidden_keys` — payload-shape validation gate.
28
+ - :func:`coerce_fk_values` — accept FK envelope on input.
29
+ - :func:`form_errors_to_envelope`
30
+ — Django form errors → wire shape.
31
+ - :func:`merged_initial_for_update`
32
+ — instance + payload → form data.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ from typing import Any
39
+
40
+ from django.contrib.admin.options import ModelAdmin
41
+ from django.db.models import ForeignKey
42
+ from django.db.models import ManyToManyField
43
+ from django.db.models import Model
44
+ from django.http import HttpRequest
45
+ from django.http import HttpResponse
46
+ from django.http import JsonResponse
47
+
48
+ from django_admin_react.api.serializers import filter_sensitive
49
+ from django_admin_react.api.serializers import is_sensitive_field_name
50
+
51
+ # Canonical 404 body. Deliberately omits the requested app/model/pk —
52
+ # leaking those would give an attacker an oracle for what *would* have
53
+ # existed had they been authorized. See ``SECURITY.md`` §3 rule 12.
54
+ _NOT_FOUND_BODY: dict[str, Any] = {
55
+ "error": {"code": "not_found", "message": "Not found."},
56
+ }
57
+
58
+
59
+ # --------------------------------------------------------------------------- #
60
+ # Response factories #
61
+ # --------------------------------------------------------------------------- #
62
+ def bad_request(message: str = "Malformed request.") -> HttpResponse:
63
+ """Return the package's canonical 400 ``bad_request`` envelope.
64
+
65
+ The ``message`` is safe to surface to the client; never include
66
+ request-derived strings here unless they are ``repr()``-quoted
67
+ (``SECURITY.md`` §3 rule 12).
68
+ """
69
+ body = {"error": {"code": "bad_request", "message": message}}
70
+ response = JsonResponse(body, status=400)
71
+ response["Cache-Control"] = "no-store"
72
+ return response
73
+
74
+
75
+ def validation_failed(errors: dict[str, list[str]]) -> HttpResponse:
76
+ """Return a 400 ``validation_failed`` envelope (contract §6).
77
+
78
+ The ``errors`` mapping is already the wire shape — see
79
+ :func:`form_errors_to_envelope` for the canonical converter from
80
+ Django's ``form.errors`` to this shape.
81
+ """
82
+ body = {
83
+ "error": {
84
+ "code": "validation_failed",
85
+ "message": "One or more fields are invalid.",
86
+ "fields": errors,
87
+ }
88
+ }
89
+ response = JsonResponse(body, status=400)
90
+ response["Cache-Control"] = "no-store"
91
+ return response
92
+
93
+
94
+ def not_found_response() -> HttpResponse:
95
+ """Return the package's canonical 404 envelope (contract §6).
96
+
97
+ Single source of truth for 404 bodies across the view layer; every
98
+ view that needs to emit a 404 imports this rather than rolling its
99
+ own envelope (keeps the leak surface to zero, per
100
+ ``SECURITY.md`` §3 rule 12).
101
+ """
102
+ response = JsonResponse(_NOT_FOUND_BODY, status=404)
103
+ response["Cache-Control"] = "no-store"
104
+ return response
105
+
106
+
107
+ # --------------------------------------------------------------------------- #
108
+ # Request / object lookup #
109
+ # --------------------------------------------------------------------------- #
110
+ def parse_json_body(request: HttpRequest) -> dict[str, Any] | HttpResponse:
111
+ """Decode ``request.body`` as a JSON object, or return a 400.
112
+
113
+ Returns:
114
+ - ``{}`` if the body is empty (PATCH with no fields is valid).
115
+ - The parsed dict on success.
116
+ - A ``HttpResponse`` (400) if the body is invalid UTF-8, invalid
117
+ JSON, or not a JSON object (arrays, scalars, etc. are
118
+ rejected — the contract only ever speaks objects).
119
+
120
+ Callers check ``isinstance(result, HttpResponse)`` to branch.
121
+ """
122
+ raw = request.body or b""
123
+ if not raw:
124
+ return {}
125
+ try:
126
+ decoded = json.loads(raw.decode("utf-8"))
127
+ except (UnicodeDecodeError, json.JSONDecodeError):
128
+ return bad_request("Request body must be valid UTF-8 JSON.")
129
+ if not isinstance(decoded, dict):
130
+ return bad_request("Request body must be a JSON object.")
131
+ return decoded
132
+
133
+
134
+ def load_object_or_none(
135
+ model: type[Model],
136
+ model_admin: ModelAdmin,
137
+ request: HttpRequest,
138
+ pk: Any,
139
+ ) -> Model | None:
140
+ """Fetch one row through the admin's queryset, or ``None`` on miss.
141
+
142
+ Three lookup failures collapse to ``None`` (callers convert to
143
+ 404, never to 500):
144
+
145
+ - ``DoesNotExist`` — pk valid but no matching row, or the row was
146
+ filtered out by ``ModelAdmin.get_queryset(request)``.
147
+ - ``ValueError`` — pk could not be parsed for the field's type
148
+ (e.g., ``"abc"`` against an ``IntegerField``).
149
+ - ``TypeError`` — similar parse failure for a custom pk type.
150
+
151
+ Centralizing this here means every "load or 404" call site has
152
+ the same exception surface and the same security posture
153
+ (queryset never bypassed, parse errors never leak).
154
+ """
155
+ try:
156
+ return model_admin.get_queryset(request).get(pk=pk)
157
+ except (model.DoesNotExist, ValueError, TypeError):
158
+ return None
159
+
160
+
161
+ # --------------------------------------------------------------------------- #
162
+ # Field-set computation #
163
+ # --------------------------------------------------------------------------- #
164
+ def writable_field_names(
165
+ model: type[Model],
166
+ model_admin: ModelAdmin,
167
+ request: HttpRequest,
168
+ obj: Model | None,
169
+ ) -> list[str]:
170
+ """Field names the API will accept in a create or update payload.
171
+
172
+ Computed as ``get_fields`` minus ``get_exclude`` minus
173
+ ``get_readonly_fields`` minus the sensitive-name denylist
174
+ (``ACCEPTANCE.md`` §4.7 S-31) minus ``ManyToManyField``
175
+ (unsupported in v1 per ``docs/api-contract.md`` §4).
176
+
177
+ Defense-in-depth: even if a ``ModelAdmin`` author forgets to
178
+ ``exclude`` a sensitive-named field, the substring match keeps
179
+ it out of the writable set.
180
+ """
181
+ declared = list(model_admin.get_fields(request, obj) or ())
182
+ excluded = set(model_admin.get_exclude(request, obj) or ())
183
+ readonly = set(model_admin.get_readonly_fields(request, obj) or ())
184
+ out: list[str] = []
185
+ for name in declared:
186
+ if name in excluded or name in readonly or is_sensitive_field_name(name):
187
+ continue
188
+ if isinstance(_safe_get_field(model, name), ManyToManyField):
189
+ continue
190
+ out.append(name)
191
+ return filter_sensitive(out)
192
+
193
+
194
+ def readonly_or_excluded_names(
195
+ model_admin: ModelAdmin,
196
+ request: HttpRequest,
197
+ obj: Model | None,
198
+ ) -> set[str]:
199
+ """Field names a payload may not even mention.
200
+
201
+ Used by :func:`reject_forbidden_keys` to emit the precise message
202
+ ``"Field 'x' is read-only."`` instead of the generic
203
+ ``"Unknown field 'x'."`` when the admin marks a field as
204
+ readonly or excluded. Both responses are 400 per contract §5;
205
+ the distinction is a UX courtesy for the SPA.
206
+ """
207
+ excluded = set(model_admin.get_exclude(request, obj) or ())
208
+ readonly = set(model_admin.get_readonly_fields(request, obj) or ())
209
+ return excluded | readonly
210
+
211
+
212
+ # --------------------------------------------------------------------------- #
213
+ # Payload validation #
214
+ # --------------------------------------------------------------------------- #
215
+ def reject_forbidden_keys(
216
+ payload: dict[str, Any],
217
+ writable: list[str],
218
+ forbidden: set[str],
219
+ ) -> HttpResponse | None:
220
+ """Validate the shape of a write payload — return a 400 or ``None``.
221
+
222
+ Three rejection reasons, each yielding ``bad_request`` per
223
+ ``docs/api-contract.md`` §6:
224
+
225
+ 1. The key is read-only or excluded by the admin (§5.2).
226
+ 2. The key matches the sensitive-name denylist
227
+ (defense-in-depth, even if the admin's ``writable`` list let
228
+ it through somehow).
229
+ 3. The key is not in ``writable`` at all (§5.1, unknown field).
230
+
231
+ Rejecting *before* form construction means a hostile payload
232
+ cannot trigger ``ModelForm`` side effects (e.g. FK queries) on
233
+ field names the admin never declared.
234
+ """
235
+ writable_set = set(writable)
236
+ for key in payload:
237
+ if key in forbidden:
238
+ return bad_request(f"Field {key!r} is read-only.")
239
+ if is_sensitive_field_name(key):
240
+ return bad_request(f"Field {key!r} is not writable.")
241
+ if key not in writable_set:
242
+ return bad_request(f"Unknown field {key!r}.")
243
+ return None
244
+
245
+
246
+ def coerce_fk_values(
247
+ payload: dict[str, Any],
248
+ model: type[Model],
249
+ ) -> dict[str, Any]:
250
+ """Normalize ForeignKey values to the bare pk the form layer expects.
251
+
252
+ The wire contract sends FKs *out* as ``{"id": pk, "label": str}``
253
+ (§4) but accepts bare pk *in* (§5.1). Clients that echo the read
254
+ shape back would otherwise hit a form-validation error even when
255
+ their intent is clear. Recognizing the envelope on input keeps
256
+ the SPA's edit-in-place flow honest without weakening
257
+ validation — Django will still reject any pk that does not
258
+ resolve to a real related row.
259
+ """
260
+ out: dict[str, Any] = {}
261
+ for key, value in payload.items():
262
+ model_field = _safe_get_field(model, key)
263
+ is_fk_envelope = (
264
+ isinstance(model_field, ForeignKey) and isinstance(value, dict) and "id" in value
265
+ )
266
+ out[key] = value["id"] if is_fk_envelope else value
267
+ return out
268
+
269
+
270
+ def form_errors_to_envelope(form: Any) -> dict[str, list[str]]:
271
+ """Convert a Django form's ``errors`` mapping to the wire shape.
272
+
273
+ Non-field errors are surfaced under the empty-string key
274
+ (normalized from Django's ``__all__`` convention so clients
275
+ don't have to know that magic name).
276
+ """
277
+ errors: dict[str, list[str]] = {}
278
+ for field_name, error_list in form.errors.items():
279
+ key = "" if field_name == "__all__" else field_name
280
+ errors[key] = [str(e) for e in error_list]
281
+ return errors
282
+
283
+
284
+ def merged_initial_for_update(
285
+ obj: Model,
286
+ writable: list[str],
287
+ payload: dict[str, Any],
288
+ model: type[Model],
289
+ ) -> dict[str, Any]:
290
+ """Build the ``data`` dict for a PATCH form: instance values + payload.
291
+
292
+ Django ``ModelForm`` validates the whole form on every save, even
293
+ on partial updates. We therefore seed every writable field with
294
+ the instance's current value, then overlay the user-supplied
295
+ payload. This mirrors what Django admin's change-view does.
296
+
297
+ FK fields are seeded with ``<name>_id`` because that is the wire
298
+ shape the form's choice field expects — passing the related
299
+ instance directly would trigger an extra DB query on a hot path.
300
+ """
301
+ merged: dict[str, Any] = {}
302
+ for name in writable:
303
+ if isinstance(_safe_get_field(model, name), ForeignKey):
304
+ merged[name] = getattr(obj, f"{name}_id", None)
305
+ else:
306
+ merged[name] = getattr(obj, name, None)
307
+ merged.update(coerce_fk_values(payload, model))
308
+ return merged
309
+
310
+
311
+ # --------------------------------------------------------------------------- #
312
+ # Internals #
313
+ # --------------------------------------------------------------------------- #
314
+ def _safe_get_field(model: type[Model], name: str):
315
+ """Return ``model._meta.get_field(name)`` or ``None`` if not found.
316
+
317
+ Centralized so callers don't need to know that ``get_field``
318
+ raises ``FieldDoesNotExist`` (or any subclass) — the contract
319
+ here is "field or None", which is what every call site in this
320
+ module actually wants.
321
+ """
322
+ try:
323
+ return model._meta.get_field(name)
324
+ except Exception:
325
+ return None
@@ -0,0 +1,27 @@
1
+ """Django AppConfig for django_admin_react.
2
+
3
+ Registering this AppConfig in the consumer's `INSTALLED_APPS` is the
4
+ only side effect of adding the package. The real wiring (URLs, API,
5
+ templates, static assets) is opt-in via the consumer's own `urls.py`.
6
+ """
7
+
8
+ from django.apps import AppConfig
9
+
10
+
11
+ class DjangoAdminReactConfig(AppConfig):
12
+ """Django app config — the only side effect of adding the package.
13
+
14
+ The four attributes are the standard Django ``AppConfig`` contract:
15
+
16
+ - ``name`` — Python import path; required by Django's app registry.
17
+ - ``label`` — short identifier used in migrations and admin URLs.
18
+ - ``verbose_name`` — human-readable name shown in the admin index.
19
+ - ``default_auto_field`` — bigint primary keys for any future models
20
+ the package adds (none today, but pinning the default avoids a
21
+ Django warning and locks the choice in for forwards compat).
22
+ """
23
+
24
+ name = "django_admin_react"
25
+ label = "django_admin_react"
26
+ verbose_name = "Django Admin React"
27
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,77 @@
1
+ """Lazy settings wrapper for django_admin_react.
2
+
3
+ All package settings live under a single optional dict
4
+ ``settings.DJANGO_ADMIN_REACT``. Defaults are applied lazily so that
5
+ adding the app to ``INSTALLED_APPS`` does not require a settings entry.
6
+
7
+ Usage in package code:
8
+
9
+ from django_admin_react.conf import settings
10
+ settings.MAX_PAGE_SIZE
11
+
12
+ Nothing in the package should read ``django.conf.settings.DJANGO_ADMIN_REACT``
13
+ directly — go through this module so defaults are consistent.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from django.conf import settings as django_settings
22
+
23
+ DEFAULTS: dict[str, Any] = {
24
+ "ADMIN_SITE": "django.contrib.admin.site",
25
+ "DEFAULT_PAGE_SIZE": 25,
26
+ "MAX_PAGE_SIZE": 200,
27
+ "ENABLE_PROFILING": False,
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class _PackageSettings:
33
+ """Resolved package settings.
34
+
35
+ Real implementation lands in PR #2. For now this is a stub so other
36
+ modules can import the typed attribute names.
37
+ """
38
+
39
+ ADMIN_SITE: str = DEFAULTS["ADMIN_SITE"]
40
+ DEFAULT_PAGE_SIZE: int = DEFAULTS["DEFAULT_PAGE_SIZE"]
41
+ MAX_PAGE_SIZE: int = DEFAULTS["MAX_PAGE_SIZE"]
42
+ ENABLE_PROFILING: bool = DEFAULTS["ENABLE_PROFILING"]
43
+
44
+
45
+ def _load() -> _PackageSettings:
46
+ """Merge the consumer's overrides with ``DEFAULTS``.
47
+
48
+ Unknown keys raise ``ValueError`` so a typo in
49
+ ``settings.DJANGO_ADMIN_REACT`` is caught at startup rather than
50
+ silently ignored. Returns an immutable ``_PackageSettings``.
51
+ """
52
+ user_overrides = getattr(django_settings, "DJANGO_ADMIN_REACT", {}) or {}
53
+ merged = {**DEFAULTS, **user_overrides}
54
+ # Reject unknown keys defensively to surface typos early.
55
+ unknown = set(merged) - set(DEFAULTS)
56
+ if unknown:
57
+ raise ValueError("Unknown DJANGO_ADMIN_REACT keys: " + ", ".join(sorted(unknown)))
58
+ return _PackageSettings(**merged)
59
+
60
+
61
+ # Lazily resolve on first access; cache.
62
+ _cached: _PackageSettings | None = None
63
+
64
+
65
+ def __getattr__(name: str) -> Any: # pragma: no cover — thin shim
66
+ """Module-level ``__getattr__`` (PEP 562) so callers can write
67
+ ``from django_admin_react.conf import settings`` or
68
+ ``conf.MAX_PAGE_SIZE`` without a separate accessor.
69
+
70
+ First access triggers ``_load()`` and caches the result; later
71
+ accesses return cached attributes. Reload requires re-importing
72
+ the module (matches Django's own settings semantics).
73
+ """
74
+ global _cached
75
+ if _cached is None:
76
+ _cached = _load()
77
+ return getattr(_cached, name)
@@ -0,0 +1,11 @@
1
+ {
2
+ "index.html": {
3
+ "file": "assets/index-itk7hrnq.js",
4
+ "name": "index",
5
+ "src": "index.html",
6
+ "isEntry": true,
7
+ "css": [
8
+ "assets/index-CKxeWYBA.css"
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.visible{visibility:visible}.col-span-2{grid-column:span 2 / span 2}.mb-1{margin-bottom:.25rem}.mb-6{margin-bottom:1.5rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-10{height:2.5rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-4{width:1rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-prose{max-width:65ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(243 244 246 / var(--tw-divide-opacity, 1))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-blue-600{--tw-border-opacity: 1;border-color:rgb(37 99 235 / var(--tw-border-opacity, 1))}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1))}.border-gray-300{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-600{--tw-border-opacity: 1;border-color:rgb(220 38 38 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-green-50{--tw-bg-opacity: 1;background-color:rgb(240 253 244 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.tracking-wide{letter-spacing:.025em}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-blue-700{--tw-text-opacity: 1;color:rgb(29 78 216 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.text-green-700{--tw-text-opacity: 1;color:rgb(21 128 61 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}html,body,#root{height:100%}.hover\:bg-blue-700:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-50:hover{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-800:hover{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.hover\:bg-red-700:hover{--tw-bg-opacity: 1;background-color:rgb(185 28 28 / var(--tw-bg-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.focus\:border-blue-500:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width: 640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}