flask-construct-ui 0.10.0__tar.gz → 0.10.2__tar.gz

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 (88) hide show
  1. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/PKG-INFO +3 -3
  2. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/README.md +2 -0
  3. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/auth.py +91 -6
  4. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/base.py +34 -3
  5. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/components.py +37 -1
  6. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/crud.py +106 -457
  7. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/data.py +8 -1
  8. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/data_sqlalchemy.py +9 -6
  9. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/orgmode.py +25 -0
  10. flask_construct_ui-0.10.2/flask_construct_ui/static/js/construct-core.min.js +1 -0
  11. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/css/whitelabel.css +14 -0
  12. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/designs/adminlte.css +15 -0
  13. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/designs/sandstone.css +24 -0
  14. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/_form_field.html +4 -0
  15. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/listindex.html +2 -0
  16. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_list.html +99 -4
  17. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/style/_flashes.html +1 -1
  18. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/style/base_full.html +6 -1
  19. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui.egg-info/PKG-INFO +3 -3
  20. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui.egg-info/SOURCES.txt +0 -3
  21. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui.egg-info/requires.txt +0 -3
  22. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/pyproject.toml +2 -2
  23. flask_construct_ui-0.10.0/flask_construct_ui/static/js/construct-core.min.js +0 -1
  24. flask_construct_ui-0.10.0/flask_construct_ui/templates/themes/whitelabel/crud/_related_child_form.html +0 -98
  25. flask_construct_ui-0.10.0/flask_construct_ui/templates/themes/whitelabel/crud/_related_child_list.html +0 -136
  26. flask_construct_ui-0.10.0/flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_child_view.html +0 -33
  27. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/LICENSE +0 -0
  28. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/__init__.py +0 -0
  29. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/csrf.py +0 -0
  30. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/forms.py +0 -0
  31. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/headless.py +0 -0
  32. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/icons.py +0 -0
  33. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/l10n/en/main.flt +0 -0
  34. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/l10n/sv/main.flt +0 -0
  35. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/language.py +0 -0
  36. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/navbar.py +0 -0
  37. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/navigation.py +0 -0
  38. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/pagination.py +0 -0
  39. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/py.typed +0 -0
  40. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/sanitize.py +0 -0
  41. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/schema.py +0 -0
  42. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
  43. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/css/unpoly.min.css +0 -0
  44. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
  45. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/js/prosemirror-bundle.min.js +0 -0
  46. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/js/unpoly.min.js +0 -0
  47. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/LICENSE.txt +0 -0
  48. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/bootstrap-icons.min.css +0 -0
  49. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  50. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  51. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/LICENSE.txt +0 -0
  52. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  53. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols.css +0 -0
  54. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/structure/base.html +0 -0
  55. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/structure/flashes.html +0 -0
  56. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/structure/public.html +0 -0
  57. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/button.html +0 -0
  58. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/calendar.html +0 -0
  59. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/calendarmini.html +0 -0
  60. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/card.html +0 -0
  61. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/chart.html +0 -0
  62. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/form.html +0 -0
  63. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/grid_layout.html +0 -0
  64. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/horizontal_layout.html +0 -0
  65. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/kanban.html +0 -0
  66. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/live.html +0 -0
  67. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/main.html +0 -0
  68. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/markup.html +0 -0
  69. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/rich_text.html +0 -0
  70. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/table.html +0 -0
  71. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/tabs.html +0 -0
  72. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/text.html +0 -0
  73. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/components/vertical_layout.html +0 -0
  74. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/_macros.html +0 -0
  75. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/_many_related_options.html +0 -0
  76. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/_related_options.html +0 -0
  77. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/create.html +0 -0
  78. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/edit.html +0 -0
  79. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_form.html +0 -0
  80. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/crud/tab_view.html +0 -0
  81. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/errors/forbidden.html +0 -0
  82. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/navbar/notifications.html +0 -0
  83. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/navbar/search_results.html +0 -0
  84. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/templates/themes/whitelabel/style/base_partial.html +0 -0
  85. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui/validation.py +0 -0
  86. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
  87. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/flask_construct_ui.egg-info/top_level.txt +0 -0
  88. {flask_construct_ui-0.10.0 → flask_construct_ui-0.10.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-construct-ui
3
- Version: 0.10.0
3
+ Version: 0.10.2
4
4
  Summary: A UI toolkit for Flask with CRUD and component features.
5
5
  Author-email: Olof Tjerngren <olof@tjerngren.net>
6
6
  License-Expression: LGPL-3.0-or-later
@@ -30,8 +30,6 @@ Requires-Dist: pygal
30
30
  Requires-Dist: markdown-it-py
31
31
  Requires-Dist: SQLAlchemy
32
32
  Requires-Dist: orgparse
33
- Provides-Extra: security
34
- Requires-Dist: flask-security-too; extra == "security"
35
33
  Provides-Extra: oidc
36
34
  Requires-Dist: flask-oidc; extra == "oidc"
37
35
  Dynamic: license-file
@@ -42,6 +40,8 @@ A UI toolkit for Flask with CRUD and component features.
42
40
  Very influenced by flask-admin and flask-restx, used for quick admin UIs.
43
41
  Recommended user authentication is oidc if required.
44
42
 
43
+ > Optional flask-security-too package removed until security issue fixed.
44
+
45
45
  ## Documentation
46
46
 
47
47
  The [user manual](https://gitlab.com/Resample2545/flask-construct-ui/-/blob/release/manual/index.org?ref_type=heads) best read on the release branch to not get fooled.
@@ -4,6 +4,8 @@ A UI toolkit for Flask with CRUD and component features.
4
4
  Very influenced by flask-admin and flask-restx, used for quick admin UIs.
5
5
  Recommended user authentication is oidc if required.
6
6
 
7
+ > Optional flask-security-too package removed until security issue fixed.
8
+
7
9
  ## Documentation
8
10
 
9
11
  The [user manual](https://gitlab.com/Resample2545/flask-construct-ui/-/blob/release/manual/index.org?ref_type=heads) best read on the release branch to not get fooled.
@@ -1,14 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
4
+
3
5
  from flask import (
4
6
  abort,
5
7
  current_app,
6
8
  g,
9
+ make_response,
7
10
  redirect,
8
11
  render_template,
9
12
  request,
10
13
  url_for,
11
14
  )
15
+ from markupsafe import escape
12
16
 
13
17
  # --- Authentication & authorization ---------------------------------------
14
18
  # Construct routes are guarded fail-closed: once an ``AuthProvider`` is passed
@@ -19,10 +23,69 @@ from flask import (
19
23
  # ``before_request`` in ``base.py`` (see ``auth_protect``), which is why this
20
24
  # covers the dynamically-registered RCRUD nested routes too.
21
25
  #
22
- # The boundary is server-side, so it behaves identically with JavaScript on or
23
- # off: an unauthenticated request gets a plain 302 to the login endpoint (a
24
- # browser follows it without JS; Unpoly follows it too) and an authenticated
25
- # request lacking the required group gets a server-rendered themed 403 page.
26
+ # The boundary is server-side, so it decides the same thing with JavaScript on
27
+ # or off; only the *shape* of the answer differs. Without JS an unauthenticated
28
+ # request gets a plain 302 the browser follows. Under Unpoly a fragment request
29
+ # cannot be answered that way (see ``on_unauthenticated``), so it gets a 401
30
+ # carrying the login URL in ``X-Construct-Login``. An authenticated request
31
+ # lacking the required group gets a server-rendered themed 403 page either way.
32
+
33
+ #: Response header carrying an opaque fingerprint of the auth state a response
34
+ #: was rendered for. The full-page layout stamps the same value on
35
+ #: ``<html data-fcu-auth>``, so the browser can notice that a fragment was
36
+ #: rendered for a *different* session than the surrounding shell.
37
+ AUTH_STAMP_HEADER = "X-Construct-Auth"
38
+
39
+ #: Response header asking the browser to leave the fragment pipeline and load
40
+ #: the given login URL as a full page navigation.
41
+ LOGIN_HEADER = "X-Construct-Login"
42
+
43
+ #: Stamp value for a request with no logged-in user.
44
+ ANONYMOUS_STAMP = "anon"
45
+
46
+ _STAMP_CACHE_KEY = "_construct_auth_stamp"
47
+
48
+
49
+ def is_unpoly_request() -> bool:
50
+ """True when the current request was issued by Unpoly (i.e. JS is on)."""
51
+ return bool(request.headers.get("X-Up-Version"))
52
+
53
+
54
+ def auth_stamp(provider) -> str:
55
+ """Opaque fingerprint of the identity this request is being served for.
56
+
57
+ Returns :data:`ANONYMOUS_STAMP` when nobody is logged in, otherwise a hash
58
+ of the user name and their groups. It is hashed so that no user name or
59
+ group ever rides along in a response header — only *changes* are
60
+ observable, which is all the client needs to tell a stale shell from a
61
+ current one. Group membership is folded in so that a re-login as someone
62
+ else, or a permission change, also invalidates the rendered menu.
63
+ """
64
+ cached = g.get(_STAMP_CACHE_KEY, None)
65
+ if cached is not None:
66
+ return cached
67
+ if provider is None or not provider.is_authenticated():
68
+ stamp = ANONYMOUS_STAMP
69
+ else:
70
+ groups = ",".join(sorted(provider.current_groups()))
71
+ ident = f"{provider.current_user_name() or ''}|{groups}"
72
+ stamp = hashlib.sha256(ident.encode("utf-8")).hexdigest()[:16]
73
+ setattr(g, _STAMP_CACHE_KEY, stamp)
74
+ return stamp
75
+
76
+
77
+ def _t(msg_id: str, fallback: str) -> str:
78
+ """Translate ``msg_id``, falling back when l10n is absent or has no key.
79
+
80
+ The guard responds before ``set_language_from_browser`` has run, so this
81
+ resolves against English (see ``Translator.t``); it is only ever used for
82
+ text a JS-mode client replaces with a page navigation anyway.
83
+ """
84
+ translator = getattr(current_app, "l10n", None)
85
+ if translator is None:
86
+ return fallback
87
+ text = translator.t(msg_id)
88
+ return fallback if text == msg_id else text
26
89
 
27
90
 
28
91
  def public(f):
@@ -122,11 +185,33 @@ class AuthProvider:
122
185
  return f"{target}{sep}next={nxt}"
123
186
 
124
187
  def on_unauthenticated(self):
125
- """Default: 302 redirect to the login URL (401 if none configured)."""
188
+ """Send the user to the login page (401 when none is configured).
189
+
190
+ Plain requests get a 302 the browser follows. An Unpoly *fragment*
191
+ request cannot be answered that way: its XHR follows the redirect
192
+ itself, and a hosted IdP bounces the login endpoint to another origin,
193
+ where the redirected XHR dies on CORS — the user sees a network error
194
+ while the shell keeps showing them as logged in. Even a same-origin
195
+ login form would only be swapped into ``#main-content``, leaving the
196
+ navbar and sidebar (rendered once, by the full-page layout) advertising
197
+ a session that no longer exists.
198
+
199
+ So JS-mode requests get a 401 carrying the login URL in
200
+ :data:`LOGIN_HEADER`, which ``construct-core.js`` turns into a full
201
+ page navigation. The small body is a no-JS-style fallback for the case
202
+ where the response is rendered anyway.
203
+ """
126
204
  url = self.login_url()
127
205
  if url is None:
128
206
  abort(401)
129
- return redirect(url)
207
+ if not is_unpoly_request():
208
+ return redirect(url)
209
+ label = _t("auth-login-required", "You must log in to access this page.")
210
+ body = f'<p class="fcu-alert"><a href="{escape(url)}">{escape(label)}</a></p>'
211
+ response = make_response(body, 401)
212
+ response.headers[LOGIN_HEADER] = url
213
+ response.headers[AUTH_STAMP_HEADER] = ANONYMOUS_STAMP
214
+ return response
130
215
 
131
216
  def on_forbidden(self):
132
217
  """Default: render the themed access-denied page with HTTP 403."""
@@ -53,6 +53,7 @@ from .pagination import (
53
53
  DEFAULT_PAGE_SIZE,
54
54
  MAX_PAGE_SIZE,
55
55
  )
56
+ from .auth import AUTH_STAMP_HEADER, auth_stamp
56
57
 
57
58
 
58
59
  def list_action(name, icon):
@@ -773,6 +774,21 @@ class FlaskConstructUI:
773
774
  response.headers["Vary"] = (existing + ", X-Up-Target").lstrip(", ")
774
775
  return response
775
776
 
777
+ @app.after_request
778
+ def add_auth_stamp(response):
779
+ # Tag every HTML response with the auth state it was rendered for.
780
+ # A fragment render only replaces #main-content, so the navbar and
781
+ # sidebar cannot correct themselves when the session ends (or
782
+ # switches user) while the page sits open — a logged-out request to
783
+ # a @public page still answers 200 with fresh content under a shell
784
+ # that names the old user. The client compares this header against
785
+ # the stamp the full-page layout put on <html data-fcu-auth> and
786
+ # reloads the whole document when they disagree.
787
+ if self._auth is None or "text/html" not in response.content_type:
788
+ return response
789
+ response.headers.setdefault(AUTH_STAMP_HEADER, auth_stamp(self._auth))
790
+ return response
791
+
776
792
  @app.before_request
777
793
  def csrf_protect():
778
794
  # Only guard state-changing requests against construct-owned
@@ -1060,8 +1076,8 @@ class FlaskConstructUI:
1060
1076
  collapsible submenu group with that title instead of being placed at
1061
1077
  the top level. Pages sharing the same ``submenu`` title are collected
1062
1078
  into the same group, in the order they are added. ``submenu_icon`` sets
1063
- the icon for the group the first time it is created (themes that render
1064
- a submenu icon use it; flat themes show the title as a section header).
1079
+ the icon for the group the first time it is created; if omitted, the
1080
+ group falls back to the ``folder`` icon.
1065
1081
  """
1066
1082
  self._pages.append(page)
1067
1083
  self._blueprint_names.add(page.endpoint)
@@ -1083,7 +1099,10 @@ class FlaskConstructUI:
1083
1099
  + page.menu["link"]
1084
1100
  )
1085
1101
  if submenu is not None:
1086
- self._add_to_submenu(submenu, menu, submenu_icon)
1102
+ if submenu_icon is None:
1103
+ self._add_to_submenu(submenu, menu)
1104
+ else:
1105
+ self._add_to_submenu(submenu, menu, submenu_icon)
1087
1106
  else:
1088
1107
  self._menu.append(menu)
1089
1108
 
@@ -1151,6 +1170,18 @@ class FlaskConstructUI:
1151
1170
  def auth(self):
1152
1171
  return self._auth
1153
1172
 
1173
+ @property
1174
+ def auth_stamp(self):
1175
+ """Fingerprint of the current auth state, or None when auth is off.
1176
+
1177
+ Rendered into the full-page layout as ``<html data-fcu-auth>`` so the
1178
+ browser can compare it against the ``X-Construct-Auth`` header of later
1179
+ fragment responses. Must be called within a request context.
1180
+ """
1181
+ if self._auth is None:
1182
+ return None
1183
+ return auth_stamp(self._auth)
1184
+
1154
1185
  @property
1155
1186
  def navbar_features_available(self):
1156
1187
  """Whether navbar search/notifications should be rendered.
@@ -13,6 +13,7 @@ from markupsafe import Markup
13
13
  from textwrap import dedent
14
14
  import jinja2
15
15
  import pygal
16
+ from pygal.style import Style
16
17
  from markdown_it import MarkdownIt
17
18
  from flask import current_app, g, url_for
18
19
  from flask_construct_ui.language import LOCALE_MAPPING
@@ -45,6 +46,7 @@ class Field:
45
46
  options: Optional[Union[List[str], str, Dict[str, str]]] = None
46
47
  value: Any = None
47
48
  secret: bool = False
49
+ autofocus: bool = False
48
50
 
49
51
 
50
52
  class FieldRendererRegistry:
@@ -392,6 +394,40 @@ class FormComponent(Component):
392
394
  return result
393
395
 
394
396
 
397
+ # Theme-aware pygal style. Every value is a CSS custom property, so the numbers
398
+ # are resolved by the browser against the inline SVG's host page -- which means
399
+ # charts pick up the active design's font and colours and follow light/dark
400
+ # mode automatically, with no server-side scheme detection. Restyle charts by
401
+ # overriding these `--fcu-chart-*` tokens in a design stylesheet (see
402
+ # whitelabel.css); nothing here needs to change. `background` is left on the
403
+ # surface token (not transparent) so hover tooltips keep a readable box.
404
+ CHART_STYLE = Style(
405
+ background="var(--fcu-surface)",
406
+ plot_background="transparent",
407
+ foreground="var(--fcu-text-muted)",
408
+ foreground_strong="var(--fcu-text)",
409
+ foreground_subtle="var(--fcu-border)",
410
+ font_family="var(--fcu-chart-font-family)",
411
+ title_font_size=15,
412
+ legend_font_size=13,
413
+ label_font_size=12,
414
+ major_label_font_size=12,
415
+ value_font_size=12,
416
+ tooltip_font_size=13,
417
+ opacity=".85",
418
+ opacity_hover="1",
419
+ transition="150ms ease-in-out",
420
+ colors=(
421
+ "var(--fcu-chart-color-1)",
422
+ "var(--fcu-chart-color-2)",
423
+ "var(--fcu-chart-color-3)",
424
+ "var(--fcu-chart-color-4)",
425
+ "var(--fcu-chart-color-5)",
426
+ "var(--fcu-chart-color-6)",
427
+ ),
428
+ )
429
+
430
+
395
431
  @dataclass
396
432
  class ChartComponent(Component):
397
433
  data: List[Tuple[str, List[Any]]] = field(default_factory=list)
@@ -418,7 +454,7 @@ class ChartComponent(Component):
418
454
  ):
419
455
  is_timeseries = True
420
456
 
421
- config_kwargs = {}
457
+ config_kwargs = {"style": CHART_STYLE}
422
458
  if self.width:
423
459
  config_kwargs["width"] = self.width
424
460
  if self.height: