Skip to content

Commit b9af25d

Browse files
authoredDec 29, 2024··
Django login and logout functionality (#276)
- User login/logout features! - `reactpy_django.hooks.use_auth` to provide **persistent** `login` and `logout` functionality to your components. - `settings.py:REACTPY_AUTH_TOKEN_TIMEOUT` to control the maximum seconds before ReactPy no longer allows the browser to obtain a persistent login cookie. - `settings.py:REACTPY_CLEAN_AUTH_TOKENS` to control whether ReactPy should clean up expired authentication tokens during automatic cleanups. - The ReactPy component tree can now be forcibly re-rendered via the new `reactpy_django.hooks.use_rerender` hook.
1 parent 464b4e2 commit b9af25d

36 files changed

+892
-151
lines changed
 

‎.github/workflows/test-python.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ jobs:
2929
run: pip install --upgrade pip hatch uv
3030
- name: Run Single DB Tests
3131
run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_single_db -v
32+
33+
python-source-multi-db:
34+
runs-on: ubuntu-latest
35+
strategy:
36+
matrix:
37+
python-version: ["3.9", "3.10", "3.11", "3.12"]
38+
steps:
39+
- uses: actions/checkout@v4
40+
- uses: oven-sh/setup-bun@v2
41+
with:
42+
bun-version: latest
43+
- name: Use Python ${{ matrix.python-version }}
44+
uses: actions/setup-python@v5
45+
with:
46+
python-version: ${{ matrix.python-version }}
47+
- name: Install Python Dependencies
48+
run: pip install --upgrade pip hatch uv
3249
- name: Run Multi-DB Tests
3350
run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_multi_db -v
3451

‎CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,20 @@ Don't forget to remove deprecated code on each major release!
2121

2222
### Added
2323

24+
- User login/logout features!
25+
- `reactpy_django.hooks.use_auth` to provide **persistent** `login` and `logout` functionality to your components.
26+
- `settings.py:REACTPY_AUTH_TOKEN_MAX_AGE` to control the maximum seconds before ReactPy's login token expires.
27+
- `settings.py:REACTPY_CLEAN_AUTH_TOKENS` to control whether ReactPy should clean up expired authentication tokens during automatic cleanups.
2428
- Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component!
29+
- The ReactPy component tree can now be forcibly re-rendered via the new `reactpy_django.hooks.use_rerender` hook.
2530

2631
### Changed
2732

28-
- Refactoring of internal code to improve maintainability. No changes to public/documented API.
33+
- Refactoring of internal code to improve maintainability. No changes to publicly documented API.
34+
35+
### Fixed
36+
37+
- Fixed bug where pre-rendered components could generate a `SynchronousOnlyOperation` exception if they access a freshly logged out Django user object.
2938

3039
## [5.1.1] - 2024-12-02
3140

‎docs/examples/python/use_auth.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django.contrib.auth import get_user_model
2+
from reactpy import component, html
3+
4+
from reactpy_django.hooks import use_auth, use_user
5+
6+
7+
@component
8+
def my_component():
9+
auth = use_auth()
10+
user = use_user()
11+
12+
async def login_user(event):
13+
new_user, _created = await get_user_model().objects.aget_or_create(username="ExampleUser")
14+
await auth.login(new_user)
15+
16+
async def logout_user(event):
17+
await auth.logout()
18+
19+
return html.div(
20+
f"Current User: {user}",
21+
html.button({"onClick": login_user}, "Login"),
22+
html.button({"onClick": logout_user}, "Logout"),
23+
)

‎docs/examples/python/use_rerender.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from uuid import uuid4
2+
3+
from reactpy import component, html
4+
5+
from reactpy_django.hooks import use_rerender
6+
7+
8+
@component
9+
def my_component():
10+
rerender = use_rerender()
11+
12+
def on_click():
13+
rerender()
14+
15+
return html.div(f"UUID: {uuid4()}", html.button({"onClick": on_click}, "Rerender"))
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```python linenums="0"
2+
{% include "../examples/python/configure_asgi_middleware.py" start="# start" %}
3+
```

‎docs/src/learn/add-reactpy-to-a-django-project.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [`
8787

8888
In these situations will need to ensure you are using `#!python AuthMiddlewareStack`.
8989

90-
```python linenums="0"
91-
{% include "../../examples/python/configure_asgi_middleware.py" start="# start" %}
92-
```
90+
{% include "../../includes/auth-middleware-stack.md" %}
9391

9492
??? question "Where is my `asgi.py`?"
9593

‎docs/src/reference/hooks.md

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,86 @@ Mutation functions can be sync or async.
271271

272272
---
273273

274+
## User Hooks
275+
276+
---
277+
278+
### Use Auth
279+
280+
Provides a `#!python NamedTuple` containing `#!python async login` and `#!python async logout` functions.
281+
282+
This hook utilizes the Django's authentication framework in a way that provides **persistent** login.
283+
284+
=== "components.py"
285+
286+
```python
287+
{% include "../../examples/python/use_auth.py" %}
288+
```
289+
290+
??? example "See Interface"
291+
292+
<font size="4">**Parameters**</font>
293+
294+
`#!python None`
295+
296+
<font size="4">**Returns**</font>
297+
298+
| Type | Description |
299+
| --- | --- |
300+
| `#!python UseAuthTuple` | A named tuple containing `#!python login` and `#!python logout` async functions. |
301+
302+
??? warning "Extra Django configuration required"
303+
304+
Your ReactPy WebSocket must utilize `#!python AuthMiddlewareStack` in order to use this hook.
305+
306+
{% include "../../includes/auth-middleware-stack.md" %}
307+
308+
??? question "Why use this instead of `#!python channels.auth.login`?"
309+
310+
The `#!python channels.auth.*` functions cannot trigger re-renders of your ReactPy components. Additionally, they do not provide persistent authentication when used within ReactPy.
311+
312+
Django's authentication design requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies.
313+
314+
To work around this limitation, when `#!python use_auth().login()` is called within your application, ReactPy performs the following process...
315+
316+
1. The server authenticates the user into the WebSocket session
317+
2. The server generates a temporary login token linked to the WebSocket session
318+
3. The server commands the browser to fetch the login token via HTTP
319+
4. The client performs the HTTP request
320+
5. The server returns the HTTP response, which contains all necessary cookies
321+
6. The client stores these cookies in the browser
322+
323+
This ultimately results in persistent authentication which will be retained even if the browser tab is refreshed.
324+
325+
---
326+
327+
### Use User
328+
329+
Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
330+
331+
=== "components.py"
332+
333+
```python
334+
{% include "../../examples/python/use_user.py" %}
335+
```
336+
337+
??? example "See Interface"
338+
339+
<font size="4">**Parameters**</font>
340+
341+
`#!python None`
342+
343+
<font size="4">**Returns**</font>
344+
345+
| Type | Description |
346+
| --- | --- |
347+
| `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. |
348+
349+
---
350+
274351
### Use User Data
275352

276-
Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`.
353+
Store or retrieve a `#!python dict` containing arbitrary data specific to the connection's `#!python User`.
277354

278355
This hook is useful for storing user-specific data, such as preferences, settings, or any generic key-value pairs.
279356

@@ -522,7 +599,7 @@ You can expect this hook to provide strings such as `http://example.com`.
522599

523600
Shortcut that returns the root component's `#!python id` from the WebSocket or HTTP connection.
524601

525-
The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset when the page is refreshed.
602+
The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset only when the page is refreshed.
526603

527604
This is useful when used in combination with [`#!python use_channel_layer`](#use-channel-layer) to send messages to a specific component instance, and/or retain a backlog of messages in case that component is disconnected via `#!python use_channel_layer( ... , group_discard=False)`.
528605

@@ -546,14 +623,14 @@ This is useful when used in combination with [`#!python use_channel_layer`](#use
546623

547624
---
548625

549-
### Use User
626+
### Use Re-render
550627

551-
Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
628+
Returns a function that can be used to trigger a re-render of the entire component tree.
552629

553630
=== "components.py"
554631

555632
```python
556-
{% include "../../examples/python/use_user.py" %}
633+
{% include "../../examples/python/use_rerender.py" %}
557634
```
558635

559636
??? example "See Interface"
@@ -566,4 +643,4 @@ Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
566643

567644
| Type | Description |
568645
| --- | --- |
569-
| `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. |
646+
| `#!python Callable[[], None]` | A function that triggers a re-render of the entire component tree. |

‎docs/src/reference/settings.md

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,6 @@ These are ReactPy-Django's default settings values. You can modify these values
66

77
</p>
88

9-
!!! abstract "Note"
10-
11-
The default configuration of ReactPy is suitable for the vast majority of use cases.
12-
13-
You should only consider changing settings when the necessity arises.
14-
159
---
1610

1711
## General Settings
@@ -60,13 +54,17 @@ This file path must be valid to Django's [template finder](https://docs.djangopr
6054

6155
---
6256

57+
## Authentication Settings
58+
59+
---
60+
6361
### `#!python REACTPY_AUTH_BACKEND`
6462

6563
**Default:** `#!python "django.contrib.auth.backends.ModelBackend"`
6664

6765
**Example Value(s):** `#!python "example_project.auth.MyModelBackend"`
6866

69-
Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
67+
Dotted path to the Django authentication backend to use for ReactPy components. This is typically needed if:
7068

7169
1. You are using `#!python settings.py:REACTPY_AUTO_RELOGIN=True` and...
7270
2. You are using `#!python AuthMiddlewareStack` and...
@@ -75,6 +73,22 @@ Dotted path to the Django authentication backend to use for ReactPy components.
7573

7674
---
7775

76+
### `#!python REACTPY_AUTH_TOKEN_MAX_AGE`
77+
78+
**Default:** `#!python 30`
79+
80+
**Example Value(s):** `#!python 5`
81+
82+
Maximum seconds before ReactPy's login token expires.
83+
84+
This setting exists because Django's authentication design requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies.
85+
86+
To work around this limitation, this setting provides a maximum validity period of a temporary login token. When `#!python reactpy_django.hooks.use_auth().login()` is called within your application, ReactPy will automatically create this temporary login token and command the browser to fetch it via HTTP.
87+
88+
This setting should be a reasonably low value, but still be high enough to account for a combination of client lag, slow internet, and server response time.
89+
90+
---
91+
7892
### `#!python REACTPY_AUTO_RELOGIN`
7993

8094
**Default:** `#!python False`
@@ -141,9 +155,9 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne).
141155

142156
**Example Value(s):** `#!python True`
143157

144-
Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is typically a relatively slow operation).
158+
Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is a relatively slow operation).
145159

146-
This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place even though a single render of the parent component would have been sufficient.
160+
This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place, even though a single render would have been sufficient.
147161

148162
---
149163

@@ -270,6 +284,16 @@ Configures whether ReactPy should clean up expired component sessions during aut
270284

271285
---
272286

287+
### `#!python REACTPY_CLEAN_AUTH_TOKENS`
288+
289+
**Default:** `#!python True`
290+
291+
**Example Value(s):** `#!python False`
292+
293+
Configures whether ReactPy should clean up expired authentication tokens during automatic clean up operations.
294+
295+
---
296+
273297
### `#!python REACTPY_CLEAN_USER_DATA`
274298

275299
**Default:** `#!python True`

‎docs/src/reference/template-tag.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ The entire file path provided is loaded directly into the browser, and must have
322322

323323
This template tag configures the current page to be able to run `pyscript`.
324324

325-
You can optionally use this tag to configure the current PyScript environment. For example, you can include a list of Python packages to automatically install within the PyScript environment.
325+
You can optionally use this tag to configure the current PyScript environment, such as adding dependencies.
326326

327327
=== "my_template.html"
328328

‎pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,24 @@ extra-dependencies = [
148148
"twisted",
149149
"servestatic",
150150
"django-bootstrap5",
151+
"decorator",
152+
"playwright",
151153
]
152154

153155
[tool.hatch.envs.django.scripts]
154156
runserver = [
155157
"cd tests && python manage.py migrate --noinput",
156158
"cd tests && python manage.py runserver",
157159
]
160+
makemigrations = ["cd tests && python manage.py makemigrations"]
161+
clean = ["cd tests && python manage.py clean_reactpy -v 3"]
162+
clean_sessions = ["cd tests && python manage.py clean_reactpy --sessions -v 3"]
163+
clean_auth_tokens = [
164+
"cd tests && python manage.py clean_reactpy --auth-tokens -v 3",
165+
]
166+
clean_user_data = [
167+
"cd tests && python manage.py clean_reactpy --user-data -v 3",
168+
]
158169

159170
#######################################
160171
# >>> Hatch Documentation Scripts <<< #

‎src/js/src/components.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DjangoFormProps } from "./types";
1+
import { DjangoFormProps, HttpRequestProps } from "./types";
22
import React from "react";
33
import ReactDOM from "react-dom";
44
/**
@@ -62,3 +62,27 @@ export function DjangoForm({
6262

6363
return null;
6464
}
65+
66+
export function HttpRequest({ method, url, body, callback }: HttpRequestProps) {
67+
React.useEffect(() => {
68+
fetch(url, {
69+
method: method,
70+
body: body,
71+
})
72+
.then((response) => {
73+
response
74+
.text()
75+
.then((text) => {
76+
callback(response.status, text);
77+
})
78+
.catch(() => {
79+
callback(response.status, "");
80+
});
81+
})
82+
.catch(() => {
83+
callback(520, "");
84+
});
85+
}, []);
86+
87+
return null;
88+
}

‎src/js/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { DjangoForm, bind } from "./components";
1+
export { HttpRequest, DjangoForm, bind } from "./components";
22
export { mountComponent } from "./mount";

‎src/js/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,10 @@ export interface DjangoFormProps {
2323
onSubmitCallback: (data: Object) => void;
2424
formId: string;
2525
}
26+
27+
export interface HttpRequestProps {
28+
method: string;
29+
url: string;
30+
body: string;
31+
callback: (status: Number, response: string) => void;
32+
}

‎src/reactpy_django/auth/__init__.py

Whitespace-only changes.

‎src/reactpy_django/auth/components.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import contextlib
5+
from logging import getLogger
6+
from typing import TYPE_CHECKING, Any
7+
from uuid import uuid4
8+
9+
from django.urls import reverse
10+
from reactpy import component, hooks
11+
12+
from reactpy_django.javascript_components import HttpRequest
13+
from reactpy_django.models import AuthToken
14+
15+
if TYPE_CHECKING:
16+
from django.contrib.sessions.backends.base import SessionBase
17+
18+
_logger = getLogger(__name__)
19+
20+
21+
@component
22+
def root_manager(child: Any):
23+
"""This component is serves as the parent component for any ReactPy component tree,
24+
which allows for the management of the entire component tree."""
25+
scope = hooks.use_connection().scope
26+
_, set_rerender = hooks.use_state(uuid4)
27+
28+
@hooks.use_effect(dependencies=[])
29+
def setup_asgi_scope():
30+
"""Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
31+
any relevant actions."""
32+
scope["reactpy"]["rerender"] = rerender
33+
34+
def rerender():
35+
"""Event that can force a rerender of the entire component tree."""
36+
set_rerender(uuid4())
37+
38+
return child
39+
40+
41+
@component
42+
def auth_manager():
43+
"""This component uses a client-side component alongside an authentication token
44+
to make the client (browser) to switch the HTTP auth session, to make it match the websocket session.
45+
46+
Used to force persistent authentication between Django's websocket and HTTP stack."""
47+
from reactpy_django import config
48+
49+
sync_needed, set_sync_needed = hooks.use_state(False)
50+
token = hooks.use_ref("")
51+
scope = hooks.use_connection().scope
52+
53+
@hooks.use_effect(dependencies=[])
54+
def setup_asgi_scope():
55+
"""Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
56+
any relevant actions."""
57+
scope["reactpy"]["synchronize_auth"] = synchronize_auth
58+
59+
@hooks.use_effect(dependencies=[sync_needed])
60+
async def synchronize_auth_watchdog():
61+
"""Detect if the client has taken too long to request a auth session synchronization.
62+
63+
This effect will automatically be cancelled if the session is successfully
64+
synchronized (via effect dependencies)."""
65+
if sync_needed:
66+
await asyncio.sleep(config.REACTPY_AUTH_TOKEN_MAX_AGE + 0.1)
67+
await asyncio.to_thread(
68+
_logger.warning,
69+
f"Client did not switch authentication sessions within {config.REACTPY_AUTH_TOKEN_MAX_AGE} (REACTPY_AUTH_TOKEN_MAX_AGE) seconds.",
70+
)
71+
set_sync_needed(False)
72+
73+
async def synchronize_auth():
74+
"""Event that can command the client to switch HTTP auth sessions (to match the websocket session)."""
75+
session: SessionBase | None = scope.get("session")
76+
if not session or not session.session_key:
77+
return
78+
79+
# Delete previous token to resolve race conditions where...
80+
# 1. Login was called multiple times before the first one is completed.
81+
# 2. Login was called, but the server failed to respond to the HTTP request.
82+
if token.current:
83+
with contextlib.suppress(AuthToken.DoesNotExist):
84+
obj = await AuthToken.objects.aget(value=token.current)
85+
await obj.adelete()
86+
87+
# Create a fresh token
88+
token.set_current(str(uuid4()))
89+
90+
# Begin the process of synchronizing HTTP and websocket auth sessions
91+
obj = await AuthToken.objects.acreate(value=token.current, session_key=session.session_key)
92+
await obj.asave()
93+
set_sync_needed(True)
94+
95+
async def synchronize_auth_callback(status_code: int, response: str):
96+
"""This callback acts as a communication bridge, allowing the client to notify the server
97+
of the status of auth session switch."""
98+
set_sync_needed(False)
99+
if status_code >= 300 or status_code < 200:
100+
await asyncio.to_thread(
101+
_logger.error,
102+
f"Client returned unexpected HTTP status code ({status_code}) while trying to synchronize authentication sessions.",
103+
)
104+
105+
# If needed, synchronize authenication sessions by configuring all relevant session cookies.
106+
# This is achieved by commanding the client to perform a HTTP request to our API endpoint
107+
# that will set any required cookies.
108+
if sync_needed:
109+
return HttpRequest(
110+
{
111+
"method": "GET",
112+
"url": reverse("reactpy:auth_manager", args=[token.current]),
113+
"body": None,
114+
"callback": synchronize_auth_callback,
115+
},
116+
)
117+
118+
return None

‎src/reactpy_django/checks.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import contextlib
22
import math
33
import sys
4+
from uuid import uuid4
45

56
from django.contrib.staticfiles.finders import find
67
from django.core.checks import Error, Tags, Warning, register
@@ -37,6 +38,7 @@ def reactpy_warnings(app_configs, **kwargs):
3738
try:
3839
reverse("reactpy:web_modules", kwargs={"file": "example"})
3940
reverse("reactpy:view_to_iframe", kwargs={"dotted_path": "example"})
41+
reverse("reactpy:session_manager", args=[str(uuid4())])
4042
except Exception:
4143
warnings.append(
4244
Warning(
@@ -218,7 +220,7 @@ def reactpy_warnings(app_configs, **kwargs):
218220
)
219221
)
220222

221-
# Check if REACTPY_CLEAN_SESSION is not a valid property
223+
# Check if user misspelled REACTPY_CLEAN_SESSIONS
222224
if getattr(settings, "REACTPY_CLEAN_SESSION", None):
223225
warnings.append(
224226
Warning(
@@ -228,6 +230,27 @@ def reactpy_warnings(app_configs, **kwargs):
228230
)
229231
)
230232

233+
# Check if REACTPY_AUTH_TOKEN_MAX_AGE is a large value
234+
auth_token_timeout = config.REACTPY_AUTH_TOKEN_MAX_AGE
235+
if isinstance(auth_token_timeout, int) and auth_token_timeout > 120:
236+
warnings.append(
237+
Warning(
238+
"REACTPY_AUTH_TOKEN_MAX_AGE is set to a very large value.",
239+
hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE under 120 seconds to prevent security risks.",
240+
id="reactpy_django.W020",
241+
)
242+
)
243+
244+
# Check if REACTPY_AUTH_TOKEN_MAX_AGE is a small value
245+
if isinstance(auth_token_timeout, int) and auth_token_timeout <= 2:
246+
warnings.append(
247+
Warning(
248+
"REACTPY_AUTH_TOKEN_MAX_AGE is set to a very low value.",
249+
hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE above 2 seconds to account for client and server latency.",
250+
id="reactpy_django.W021",
251+
)
252+
)
253+
231254
return warnings
232255

233256

@@ -511,4 +534,34 @@ def reactpy_errors(app_configs, **kwargs):
511534
)
512535
)
513536

537+
# Check if REACTPY_CLEAN_AUTH_TOKENS is a valid data type
538+
if not isinstance(config.REACTPY_CLEAN_AUTH_TOKENS, bool):
539+
errors.append(
540+
Error(
541+
"Invalid type for REACTPY_CLEAN_AUTH_TOKENS.",
542+
hint="REACTPY_CLEAN_AUTH_TOKENS should be a boolean.",
543+
id="reactpy_django.E027",
544+
)
545+
)
546+
547+
# Check if REACTPY_AUTH_TOKEN_MAX_AGE is a valid data type
548+
if not isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int):
549+
errors.append(
550+
Error(
551+
"Invalid type for REACTPY_AUTH_TOKEN_MAX_AGE.",
552+
hint="REACTPY_AUTH_TOKEN_MAX_AGE should be an integer.",
553+
id="reactpy_django.E028",
554+
)
555+
)
556+
557+
# Check if REACTPY_AUTH_TOKEN_MAX_AGE is a positive integer
558+
if isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int) and config.REACTPY_AUTH_TOKEN_MAX_AGE < 0:
559+
errors.append(
560+
Error(
561+
"Invalid value for REACTPY_AUTH_TOKEN_MAX_AGE.",
562+
hint="REACTPY_AUTH_TOKEN_MAX_AGE should be a non-negative integer.",
563+
id="reactpy_django.E029",
564+
)
565+
)
566+
514567
return errors

‎src/reactpy_django/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
"REACTPY_SESSION_MAX_AGE",
4040
259200, # Default to 3 days
4141
)
42+
REACTPY_AUTH_TOKEN_MAX_AGE: int = getattr(
43+
settings,
44+
"REACTPY_AUTH_TOKEN_MAX_AGE",
45+
30, # Default to 30 seconds
46+
)
4247
REACTPY_CACHE: str = getattr(
4348
settings,
4449
"REACTPY_CACHE",
@@ -121,6 +126,11 @@
121126
"REACTPY_CLEAN_SESSIONS",
122127
True,
123128
)
129+
REACTPY_CLEAN_AUTH_TOKENS: bool = getattr(
130+
settings,
131+
"REACTPY_CLEAN_AUTH_TOKENS",
132+
True,
133+
)
124134
REACTPY_CLEAN_USER_DATA: bool = getattr(
125135
settings,
126136
"REACTPY_CLEAN_USER_DATA",

‎src/reactpy_django/forms/components.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,11 @@ def _django_form(
4949
):
5050
from reactpy_django import config
5151

52-
uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
52+
uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
5353
top_children_count = hooks.use_ref(len(top_children))
5454
bottom_children_count = hooks.use_ref(len(bottom_children))
5555
submitted_data, set_submitted_data = hooks.use_state({} or None)
5656
rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None))
57-
uuid = uuid_ref.current
5857

5958
# Initialize the form with the provided data
6059
validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form)

‎src/reactpy_django/hooks.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import orjson
1717
from channels import DEFAULT_CHANNEL_LAYER
18+
from channels import auth as channels_auth
1819
from channels.layers import InMemoryChannelLayer, get_channel_layer
1920
from reactpy import use_callback, use_effect, use_memo, use_ref, use_state
2021
from reactpy import use_connection as _use_connection
@@ -32,6 +33,7 @@
3233
Mutation,
3334
Query,
3435
SyncPostprocessor,
36+
UseAuthTuple,
3537
UserData,
3638
)
3739
from reactpy_django.utils import django_query_postprocessor, ensure_async, generate_obj_name, get_pk
@@ -416,6 +418,41 @@ def use_root_id() -> str:
416418
return scope["reactpy"]["id"]
417419

418420

421+
def use_rerender() -> Callable[[], None]:
422+
"""Provides a callable that can re-render the entire component tree without disconnecting the websocket."""
423+
scope = use_scope()
424+
425+
def rerender():
426+
scope["reactpy"]["rerender"]()
427+
428+
return rerender
429+
430+
431+
def use_auth() -> UseAuthTuple:
432+
"""Provides the ability to login/logout a user using Django's authentication framework."""
433+
from reactpy_django import config
434+
435+
scope = use_scope()
436+
trigger_rerender = use_rerender()
437+
438+
async def login(user: AbstractUser, rerender: bool = True) -> None:
439+
await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
440+
session_save_method = getattr(scope["session"], "asave", scope["session"].save)
441+
await ensure_async(session_save_method)()
442+
await scope["reactpy"]["synchronize_auth"]()
443+
444+
if rerender:
445+
trigger_rerender()
446+
447+
async def logout(rerender: bool = True) -> None:
448+
await channels_auth.logout(scope)
449+
450+
if rerender:
451+
trigger_rerender()
452+
453+
return UseAuthTuple(login=login, logout=logout)
454+
455+
419456
async def _get_user_data(user: AbstractUser, default_data: None | dict, save_default_data: bool) -> dict | None:
420457
"""The mutation function for `use_user_data`"""
421458
from reactpy_django.models import UserDataModel

‎src/reactpy_django/http/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,9 @@
1515
views.view_to_iframe,
1616
name="view_to_iframe",
1717
),
18+
path(
19+
"auth/<uuid:uuid>",
20+
views.auth_manager,
21+
name="auth_manager",
22+
),
1823
]

‎src/reactpy_django/http/views.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.http import FileResponse, HttpRequest, HttpResponse, HttpResponseNotFound
66
from reactpy.config import REACTPY_WEB_MODULES_DIR
77

8-
from reactpy_django.utils import FileAsyncIterator, render_view
8+
from reactpy_django.utils import FileAsyncIterator, ensure_async, render_view
99

1010

1111
def web_modules_file(request: HttpRequest, file: str) -> FileResponse:
@@ -42,3 +42,43 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
4242
# Ensure page can be rendered as an iframe
4343
response["X-Frame-Options"] = "SAMEORIGIN"
4444
return response
45+
46+
47+
async def auth_manager(request: HttpRequest, uuid: str) -> HttpResponse:
48+
"""Switches the client's active auth session to match ReactPy's session.
49+
50+
This view exists because ReactPy is rendered via WebSockets, and browsers do not
51+
allow active WebSocket connections to modify cookies. Django's authentication
52+
design requires HTTP cookies to persist state changes.
53+
"""
54+
from reactpy_django.models import AuthToken
55+
56+
# Find out what session the client wants to switch to
57+
token = await AuthToken.objects.aget(value=uuid)
58+
59+
# CHECK: Token has expired?
60+
if token.expired:
61+
msg = "Session expired."
62+
await token.adelete()
63+
raise SuspiciousOperation(msg)
64+
65+
# CHECK: Token does not exist?
66+
exists_method = getattr(request.session, "aexists", request.session.exists)
67+
if not await ensure_async(exists_method)(token.session_key):
68+
msg = "Attempting to switch to a session that does not exist."
69+
raise SuspiciousOperation(msg)
70+
71+
# CHECK: Client already using the correct session key?
72+
if request.session.session_key == token.session_key:
73+
await token.adelete()
74+
return HttpResponse(status=204)
75+
76+
# Switch the client's session
77+
request.session = type(request.session)(session_key=token.session_key)
78+
load_method = getattr(request.session, "aload", request.session.load)
79+
await ensure_async(load_method)()
80+
request.session.modified = True
81+
save_method = getattr(request.session, "asave", request.session.save)
82+
await ensure_async(save_method)()
83+
await token.adelete()
84+
return HttpResponse(status=204)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from reactpy import web
6+
7+
HttpRequest = web.export(
8+
web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"),
9+
("HttpRequest"),
10+
)
Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from logging import getLogger
2-
from typing import Literal
32

43
from django.core.management.base import BaseCommand
54

@@ -9,18 +8,12 @@
98
class Command(BaseCommand):
109
help = "Manually clean ReactPy data. When using this command without args, it will perform all cleaning operations."
1110

12-
def handle(self, **options):
13-
from reactpy_django.tasks import clean
11+
def handle(self, *_args, **options):
12+
from reactpy_django.tasks import CleaningArgs, clean
1413

15-
verbosity = options.get("verbosity", 1)
16-
17-
cleaning_args: set[Literal["all", "sessions", "user_data"]] = set()
18-
if options.get("sessions"):
19-
cleaning_args.add("sessions")
20-
if options.get("user_data"):
21-
cleaning_args.add("user_data")
22-
if not cleaning_args:
23-
cleaning_args = {"all"}
14+
verbosity = options.pop("verbosity", 1)
15+
valid_args: set[CleaningArgs] = {"all", "sessions", "auth_tokens", "user_data"}
16+
cleaning_args: set[CleaningArgs] = {arg for arg in options if arg in valid_args and options[arg]} or {"all"}
2417

2518
clean(*cleaning_args, immediate=True, verbosity=verbosity)
2619

@@ -31,10 +24,15 @@ def add_arguments(self, parser):
3124
parser.add_argument(
3225
"--sessions",
3326
action="store_true",
34-
help="Clean session data. This value can be combined with other cleaning options.",
27+
help="Clean component session data. This value can be combined with other cleaning options.",
3528
)
3629
parser.add_argument(
3730
"--user-data",
3831
action="store_true",
3932
help="Clean user data. This value can be combined with other cleaning options.",
4033
)
34+
parser.add_argument(
35+
"--auth-tokens",
36+
action="store_true",
37+
help="Clean authentication tokens. This value can be combined with other cleaning options.",
38+
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.1.4 on 2024-12-29 07:44
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('reactpy_django', '0006_userdatamodel'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='AuthToken',
15+
fields=[
16+
('value', models.UUIDField(editable=False, primary_key=True, serialize=False, unique=True)),
17+
('session_key', models.CharField(editable=False, max_length=40)),
18+
('created_at', models.DateTimeField(auto_now_add=True)),
19+
],
20+
),
21+
]

‎src/reactpy_django/models.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,44 @@
1+
from datetime import timedelta
2+
13
from django.contrib.auth import get_user_model
24
from django.db import models
35
from django.db.models.signals import pre_delete
46
from django.dispatch import receiver
7+
from django.utils import timezone
58

69
from reactpy_django.utils import get_pk
710

811

912
class ComponentSession(models.Model):
10-
"""A model for storing component sessions."""
13+
"""A model for storing component sessions.
14+
15+
This is used to store component arguments provided within Django templates.
16+
These arguments are retrieved within the layout renderer (WebSocket consumer)."""
1117

1218
uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
1319
params = models.BinaryField(editable=False)
1420
last_accessed = models.DateTimeField(auto_now=True)
1521

1622

23+
class AuthToken(models.Model):
24+
"""A model that contains any relevant data needed to force Django's HTTP session to
25+
match the websocket session.
26+
27+
The session key is tied to an arbitrary UUID token for security (obfuscation) purposes.
28+
29+
Source code must be written to respect the expiration property of this model."""
30+
31+
value = models.UUIDField(primary_key=True, editable=False, unique=True)
32+
session_key = models.CharField(max_length=40, editable=False)
33+
created_at = models.DateTimeField(auto_now_add=True, editable=False)
34+
35+
@property
36+
def expired(self) -> bool:
37+
from reactpy_django.config import REACTPY_AUTH_TOKEN_MAX_AGE
38+
39+
return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_MAX_AGE))
40+
41+
1742
class Config(models.Model):
1843
"""A singleton model for storing ReactPy configuration."""
1944

‎src/reactpy_django/pyscript/components.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ def _pyscript_component(
2020
root: str = "root",
2121
):
2222
rendered, set_rendered = hooks.use_state(False)
23-
uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
24-
uuid = uuid_ref.current
23+
uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
2524
initial = reactpy_to_string(initial, uuid=uuid)
2625
executor = render_pyscript_template(file_paths, uuid, root)
2726

‎src/reactpy_django/tasks.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,41 @@
1313
from reactpy_django.models import Config
1414

1515
CLEAN_NEEDED_BY: datetime = datetime(year=1, month=1, day=1, tzinfo=timezone.now().tzinfo)
16+
CleaningArgs = Literal["all", "sessions", "auth_tokens", "user_data"]
1617

1718

18-
def clean(
19-
*args: Literal["all", "sessions", "user_data"],
20-
immediate: bool = False,
21-
verbosity: int = 1,
22-
):
19+
def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1):
2320
from reactpy_django.config import (
21+
REACTPY_CLEAN_AUTH_TOKENS,
2422
REACTPY_CLEAN_SESSIONS,
2523
REACTPY_CLEAN_USER_DATA,
2624
)
2725
from reactpy_django.models import Config
2826

2927
config = Config.load()
30-
if immediate or is_clean_needed(config):
28+
if immediate or clean_is_needed(config):
3129
config.cleaned_at = timezone.now()
3230
config.save()
31+
32+
# If no args are provided, use the default settings.
3333
sessions = REACTPY_CLEAN_SESSIONS
34+
auth_tokens = REACTPY_CLEAN_AUTH_TOKENS
3435
user_data = REACTPY_CLEAN_USER_DATA
3536

3637
if args:
3738
sessions = any(value in args for value in ("sessions", "all"))
39+
auth_tokens = any(value in args for value in ("auth_tokens", "all"))
3840
user_data = any(value in args for value in ("user_data", "all"))
3941

4042
if sessions:
41-
clean_sessions(verbosity)
43+
clean_component_sessions(verbosity)
44+
if auth_tokens:
45+
clean_auth_tokens(verbosity)
4246
if user_data:
4347
clean_user_data(verbosity)
4448

4549

46-
def clean_sessions(verbosity: int = 1):
50+
def clean_component_sessions(verbosity: int = 1):
4751
"""Deletes expired component sessions from the database.
4852
As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds.
4953
"""
@@ -67,6 +71,25 @@ def clean_sessions(verbosity: int = 1):
6771
inspect_clean_duration(start_time, "component sessions", verbosity)
6872

6973

74+
def clean_auth_tokens(verbosity: int = 1):
75+
from reactpy_django.config import DJANGO_DEBUG, REACTPY_AUTH_TOKEN_MAX_AGE
76+
from reactpy_django.models import AuthToken
77+
78+
if verbosity >= 2:
79+
_logger.info("Cleaning ReactPy auth tokens...")
80+
start_time = timezone.now()
81+
expiration_date = timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_MAX_AGE)
82+
synchronizer_objects = AuthToken.objects.filter(created_at__lte=expiration_date)
83+
84+
if verbosity >= 2:
85+
_logger.info("Deleting %d expired auth token objects...", synchronizer_objects.count())
86+
87+
synchronizer_objects.delete()
88+
89+
if DJANGO_DEBUG or verbosity >= 2:
90+
inspect_clean_duration(start_time, "auth tokens", verbosity)
91+
92+
7093
def clean_user_data(verbosity: int = 1):
7194
"""Delete any user data that is not associated with an existing `User`.
7295
This is a safety measure to ensure that we don't have any orphaned data in the database.
@@ -101,7 +124,7 @@ def clean_user_data(verbosity: int = 1):
101124
inspect_clean_duration(start_time, "user data", verbosity)
102125

103126

104-
def is_clean_needed(config: Config | None = None) -> bool:
127+
def clean_is_needed(config: Config | None = None) -> bool:
105128
"""Check if a clean is needed. This function avoids unnecessary database reads by caching the
106129
CLEAN_NEEDED_BY date."""
107130
from reactpy_django.config import REACTPY_CLEAN_INTERVAL

‎src/reactpy_django/templatetags/reactpy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def component(
144144
)
145145
_logger.error(msg)
146146
return failure_context(dotted_path, ComponentCarrierError(msg))
147+
147148
_prerender_html = prerender_component(user_component, args, kwargs, uuid, request)
148149

149150
# Fetch the offline component's HTML, if requested

‎src/reactpy_django/types.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
if TYPE_CHECKING:
2020
from collections.abc import MutableMapping, Sequence
2121

22+
from django.contrib.auth.models import AbstractUser
2223
from django.forms import Form, ModelForm
2324

2425
from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer
@@ -108,3 +109,26 @@ def __call__(
108109

109110
class ViewToIframeConstructor(Protocol):
110111
def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> ComponentType: ...
112+
113+
114+
class UseAuthLogin(Protocol):
115+
async def __call__(self, user: AbstractUser, rerender: bool = True) -> None: ...
116+
117+
118+
class UseAuthLogout(Protocol):
119+
async def __call__(self, rerender: bool = True) -> None: ...
120+
121+
122+
class UseAuthTuple(NamedTuple):
123+
login: UseAuthLogin
124+
"""Login a user.
125+
126+
Args:
127+
user: The user to login.
128+
rerender: If True, the root component will be re-rendered after the user is logged in."""
129+
130+
logout: UseAuthLogout
131+
"""Logout the current user.
132+
133+
Args:
134+
rerender: If True, the root component will be re-rendered after the user is logged out."""

‎src/reactpy_django/utils.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
import contextlib
67
import inspect
78
import logging
@@ -16,7 +17,6 @@
1617
from uuid import UUID, uuid4
1718

1819
import dill
19-
from asgiref.sync import async_to_sync
2020
from channels.db import database_sync_to_async
2121
from django.contrib.staticfiles.finders import find
2222
from django.core.cache import caches
@@ -62,6 +62,7 @@
6262
+ r"\s*%}"
6363
)
6464
FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator")
65+
SYNC_LAYOUT_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-SyncLayout")
6566

6667

6768
async def render_view(
@@ -353,14 +354,16 @@ class SyncLayout(Layout):
353354
"""
354355

355356
def __enter__(self):
356-
async_to_sync(self.__aenter__)()
357+
self.loop = asyncio.new_event_loop()
358+
SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aenter__()).result()
357359
return self
358360

359-
def __exit__(self, *_):
360-
async_to_sync(self.__aexit__)(*_)
361+
def __exit__(self, exc_type, exc_val, exc_tb):
362+
SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__()).result()
363+
self.loop.close()
361364

362365
def sync_render(self):
363-
return async_to_sync(super().render)()
366+
return SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.render()).result()
364367

365368

366369
def get_pk(model):
@@ -395,6 +398,7 @@ def prerender_component(
395398
search = request.GET.urlencode()
396399
scope = getattr(request, "scope", {})
397400
scope["reactpy"] = {"id": str(uuid)}
401+
dir(request.user) # Call `dir` before prerendering to make sure the user object is loaded
398402

399403
with SyncLayout(
400404
ConnectionContext(

‎src/reactpy_django/websocket/consumer.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ async def encode_json(cls, content):
142142

143143
async def run_dispatcher(self):
144144
"""Runs the main loop that performs component rendering tasks."""
145+
# TODO: Figure out why exceptions raised in this method are not being printed to the console.
145146
from reactpy_django import models
147+
from reactpy_django.auth.components import auth_manager, root_manager
146148
from reactpy_django.config import (
147149
REACTPY_REGISTERED_COMPONENTS,
148150
REACTPY_SESSION_MAX_AGE,
@@ -210,7 +212,13 @@ async def run_dispatcher(self):
210212
# Start the ReactPy component rendering loop
211213
with contextlib.suppress(Exception):
212214
await serve_layout(
213-
Layout(ConnectionContext(root_component, value=connection)), # type: ignore
215+
Layout( # type: ignore
216+
ConnectionContext(
217+
auth_manager(),
218+
root_manager(root_component),
219+
value=connection,
220+
)
221+
),
214222
self.send_json,
215223
self.recv_queue.get,
216224
)

‎src/reactpy_django/websocket/paths.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
)
1010
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.
1111
12-
Required since the `reverse()` function does not exist for Django Channels, but ReactPy needs
13-
to know the current websocket path.
12+
This global exists since there is no way to retrieve (`reverse()`) a Django Channels URL,
13+
but ReactPy-Django needs to know the current websocket path.
1414
"""

‎tests/test_app/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0
99
assert (
1010
subprocess.run(
11-
["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js")],
11+
["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js"), "--minify"],
1212
cwd=str(js_dir),
1313
check=True,
1414
).returncode

‎tests/test_app/components.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import inspect
33
from pathlib import Path
4+
from uuid import uuid4
45

56
from channels.auth import login, logout
67
from channels.db import database_sync_to_async
@@ -692,3 +693,86 @@ async def on_submit(event):
692693
html.div(f"Mutation State: (loading={user_data_mutation.loading}, error={user_data_mutation.error})"),
693694
html.div(html.input({"on_key_press": on_submit, "placeholder": "Type here to add data"})),
694695
)
696+
697+
698+
@component
699+
def use_auth():
700+
_login, _logout = reactpy_django.hooks.use_auth()
701+
uuid = hooks.use_ref(str(uuid4())).current
702+
current_user = reactpy_django.hooks.use_user()
703+
connection = reactpy_django.hooks.use_connection()
704+
705+
async def login_user(event):
706+
new_user, _created = await get_user_model().objects.aget_or_create(username="user_4")
707+
await _login(new_user)
708+
709+
async def logout_user(event):
710+
await _logout()
711+
712+
async def disconnect(event):
713+
await connection.carrier.close()
714+
715+
return html.div(
716+
{
717+
"id": "use-auth",
718+
"data-username": ("AnonymousUser" if current_user.is_anonymous else current_user.username),
719+
"data-uuid": uuid,
720+
},
721+
html.div("use_auth"),
722+
html.div(f"UUID: {uuid}"),
723+
html.button({"className": "login", "on_click": login_user}, "Login"),
724+
html.button({"className": "logout", "on_click": logout_user}, "Logout"),
725+
html.button({"className": "disconnect", "on_click": disconnect}, "disconnect"),
726+
html.div(f"User: {current_user}"),
727+
)
728+
729+
730+
@component
731+
def use_auth_no_rerender():
732+
_login, _logout = reactpy_django.hooks.use_auth()
733+
uuid = hooks.use_ref(str(uuid4())).current
734+
current_user = reactpy_django.hooks.use_user()
735+
connection = reactpy_django.hooks.use_connection()
736+
737+
async def login_user(event):
738+
new_user, _created = await get_user_model().objects.aget_or_create(username="user_5")
739+
await _login(new_user, rerender=False)
740+
741+
async def logout_user(event):
742+
await _logout(rerender=False)
743+
744+
async def disconnect(event):
745+
await connection.carrier.close()
746+
747+
return html.div(
748+
{
749+
"id": "use-auth-no-rerender",
750+
"data-username": ("AnonymousUser" if current_user.is_anonymous else current_user.username),
751+
"data-uuid": uuid,
752+
},
753+
html.div("use_auth_no_rerender"),
754+
html.div(f"UUID: {uuid}"),
755+
html.button({"className": "login", "on_click": login_user}, "Login"),
756+
html.button({"className": "logout", "on_click": logout_user}, "Logout"),
757+
html.button({"className": "disconnect", "on_click": disconnect}, "disconnect"),
758+
html.div(f"User: {current_user}"),
759+
)
760+
761+
762+
@component
763+
def use_rerender():
764+
uuid = str(uuid4())
765+
rerender = reactpy_django.hooks.use_rerender()
766+
767+
def on_click(event):
768+
rerender()
769+
770+
return html.div(
771+
{
772+
"id": "use-rerender",
773+
"data-uuid": uuid,
774+
},
775+
html.div("use_rerender"),
776+
html.div(f"UUID: {uuid}"),
777+
html.button({"on_click": on_click}, "Rerender"),
778+
)

‎tests/test_app/templates/base.html

Lines changed: 87 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,90 +3,96 @@
33
<html lang="en">
44

55
<head>
6-
<meta charset="UTF-8" />
7-
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
8-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
9-
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}" />
10-
<title>ReactPy</title>
11-
<style>
12-
iframe {
13-
width: 100%;
14-
height: 45px;
15-
}
16-
</style>
6+
<meta charset="UTF-8" />
7+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
9+
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}" />
10+
<title>ReactPy</title>
11+
<style>
12+
iframe {
13+
width: 100%;
14+
height: 45px;
15+
}
16+
</style>
1717
</head>
1818

1919
<body>
20-
<h1>ReactPy Test Page</h1>
21-
<hr>
22-
{% component "test_app.components.hello_world" class="hello-world" %}
23-
<hr>
24-
{% component "test_app.components.button" class="button" %}
25-
<hr>
26-
{% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %}
27-
<hr>
28-
{% component "test_app.components.object_in_templatetag" my_object %}
29-
<hr>
30-
{% component "test_app.components.button_from_js_module" %}
31-
<hr>
32-
{% component "test_app.components.use_connection" %}
33-
<hr>
34-
{% component "test_app.components.use_scope" %}
35-
<hr>
36-
{% component "test_app.components.use_location" %}
37-
<hr>
38-
{% component "test_app.components.use_origin" %}
39-
<hr>
40-
{% component "test_app.components.django_css" %}
41-
<hr>
42-
{% component "test_app.components.django_js" %}
43-
<hr>
44-
{% component "test_app.components.unauthorized_user" %}
45-
<hr>
46-
{% component "test_app.components.authorized_user" %}
47-
<hr>
48-
{% component "test_app.components.relational_query" %}
49-
<hr>
50-
{% component "test_app.components.async_relational_query" %}
51-
<hr>
52-
{% component "test_app.components.todo_list" %}
53-
<hr>
54-
{% component "test_app.components.async_todo_list" %}
55-
<hr>
56-
{% component "test_app.components.view_to_component_sync_func" %}
57-
<hr>
58-
{% component "test_app.components.view_to_component_async_func" %}
59-
<hr>
60-
{% component "test_app.components.view_to_component_sync_class" %}
61-
<hr>
62-
{% component "test_app.components.view_to_component_async_class" %}
63-
<hr>
64-
{% component "test_app.components.view_to_component_template_view_class" %}
65-
<hr>
66-
{% component "test_app.components.view_to_component_script" %}
67-
<hr>
68-
{% component "test_app.components.view_to_component_request" %}
69-
<hr>
70-
{% component "test_app.components.view_to_component_args" %}
71-
<hr>
72-
{% component "test_app.components.view_to_component_kwargs" %}
73-
<hr>
74-
{% component "test_app.components.view_to_iframe_sync_func" %}
75-
<hr>
76-
{% component "test_app.components.view_to_iframe_async_func" %}
77-
<hr>
78-
{% component "test_app.components.view_to_iframe_sync_class" %}
79-
<hr>
80-
{% component "test_app.components.view_to_iframe_async_class" %}
81-
<hr>
82-
{% component "test_app.components.view_to_iframe_template_view_class" %}
83-
<hr>
84-
{% component "test_app.components.view_to_iframe_args" %}
85-
<hr>
86-
{% component "test_app.components.use_user_data" %}
87-
<hr>
88-
{% component "test_app.components.use_user_data_with_default" %}
89-
<hr>
20+
<h1>ReactPy Test Page</h1>
21+
<hr>
22+
{% component "test_app.components.hello_world" class="hello-world" %}
23+
<hr>
24+
{% component "test_app.components.button" class="button" %}
25+
<hr>
26+
{% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %}
27+
<hr>
28+
{% component "test_app.components.object_in_templatetag" my_object %}
29+
<hr>
30+
{% component "test_app.components.button_from_js_module" %}
31+
<hr>
32+
{% component "test_app.components.use_connection" %}
33+
<hr>
34+
{% component "test_app.components.use_scope" %}
35+
<hr>
36+
{% component "test_app.components.use_location" %}
37+
<hr>
38+
{% component "test_app.components.use_origin" %}
39+
<hr>
40+
{% component "test_app.components.django_css" %}
41+
<hr>
42+
{% component "test_app.components.django_js" %}
43+
<hr>
44+
{% component "test_app.components.unauthorized_user" %}
45+
<hr>
46+
{% component "test_app.components.authorized_user" %}
47+
<hr>
48+
{% component "test_app.components.relational_query" %}
49+
<hr>
50+
{% component "test_app.components.async_relational_query" %}
51+
<hr>
52+
{% component "test_app.components.todo_list" %}
53+
<hr>
54+
{% component "test_app.components.async_todo_list" %}
55+
<hr>
56+
{% component "test_app.components.view_to_component_sync_func" %}
57+
<hr>
58+
{% component "test_app.components.view_to_component_async_func" %}
59+
<hr>
60+
{% component "test_app.components.view_to_component_sync_class" %}
61+
<hr>
62+
{% component "test_app.components.view_to_component_async_class" %}
63+
<hr>
64+
{% component "test_app.components.view_to_component_template_view_class" %}
65+
<hr>
66+
{% component "test_app.components.view_to_component_script" %}
67+
<hr>
68+
{% component "test_app.components.view_to_component_request" %}
69+
<hr>
70+
{% component "test_app.components.view_to_component_args" %}
71+
<hr>
72+
{% component "test_app.components.view_to_component_kwargs" %}
73+
<hr>
74+
{% component "test_app.components.view_to_iframe_sync_func" %}
75+
<hr>
76+
{% component "test_app.components.view_to_iframe_async_func" %}
77+
<hr>
78+
{% component "test_app.components.view_to_iframe_sync_class" %}
79+
<hr>
80+
{% component "test_app.components.view_to_iframe_async_class" %}
81+
<hr>
82+
{% component "test_app.components.view_to_iframe_template_view_class" %}
83+
<hr>
84+
{% component "test_app.components.view_to_iframe_args" %}
85+
<hr>
86+
{% component "test_app.components.use_user_data" %}
87+
<hr>
88+
{% component "test_app.components.use_user_data_with_default" %}
89+
<hr>
90+
{% component "test_app.components.use_auth" %}
91+
<hr>
92+
{% component "test_app.components.use_auth_no_rerender" %}
93+
<hr>
94+
{% component "test_app.components.use_rerender" %}
95+
<hr>
9096
</body>
9197

9298
</html>

‎tests/test_app/tests/test_components.py

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_component_hello_world(self):
3131
def test_component_counter(self):
3232
for i in range(5):
3333
self.page.locator(f"#counter-num[data-count={i}]")
34-
self.page.locator("#counter-inc").click()
34+
self.page.locator("#counter-inc").click(delay=CLICK_DELAY)
3535

3636
@navigate_to_page("/")
3737
def test_component_parametrized_component(self):
@@ -106,7 +106,7 @@ def test_component_use_query_and_mutation(self):
106106
todo_input.type(f"sample-{i}", delay=CLICK_DELAY)
107107
todo_input.press("Enter", delay=CLICK_DELAY)
108108
self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}")
109-
self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click()
109+
self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY)
110110
with pytest.raises(TimeoutError):
111111
self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1)
112112

@@ -120,7 +120,7 @@ def test_component_async_use_query_and_mutation(self):
120120
todo_input.type(f"sample-{i}", delay=CLICK_DELAY)
121121
todo_input.press("Enter", delay=CLICK_DELAY)
122122
self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}")
123-
self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click()
123+
self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY)
124124
with pytest.raises(TimeoutError):
125125
self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1)
126126

@@ -147,7 +147,7 @@ def test_component_view_to_component_template_view_class(self):
147147
@navigate_to_page("/")
148148
def _click_btn_and_check_success(self, name):
149149
self.page.locator(f"#{name}:not([data-success=true])").wait_for()
150-
self.page.wait_for_selector(f"#{name}_btn").click()
150+
self.page.wait_for_selector(f"#{name}_btn").click(delay=CLICK_DELAY)
151151
self.page.locator(f"#{name}[data-success=true]").wait_for()
152152

153153
@navigate_to_page("/")
@@ -243,7 +243,7 @@ def test_component_use_user_data(self):
243243
assert "Data: None" in user_data_div.text_content()
244244

245245
# Test first user's data
246-
login_1.click()
246+
login_1.click(delay=CLICK_DELAY)
247247
user_data_div = self.page.wait_for_selector(
248248
"#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]"
249249
)
@@ -256,7 +256,7 @@ def test_component_use_user_data(self):
256256
assert "Data: {'test': 'test'}" in user_data_div.text_content()
257257

258258
# Test second user's data
259-
login_2.click()
259+
login_2.click(delay=CLICK_DELAY)
260260
user_data_div = self.page.wait_for_selector(
261261
"#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]"
262262
)
@@ -271,14 +271,14 @@ def test_component_use_user_data(self):
271271
assert "Data: {'test 2': 'test 2'}" in user_data_div.text_content()
272272

273273
# Attempt to clear data
274-
clear.click()
274+
clear.click(delay=CLICK_DELAY)
275275
user_data_div = self.page.wait_for_selector(
276276
"#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]"
277277
)
278278
assert "Data: {}" in user_data_div.text_content()
279279

280280
# Attempt to logout
281-
logout.click()
281+
logout.click(delay=CLICK_DELAY)
282282
user_data_div = self.page.wait_for_selector(
283283
"#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=AnonymousUser]"
284284
)
@@ -297,7 +297,7 @@ def test_component_use_user_data_with_default(self):
297297
assert "Data: None" in user_data_div.text_content()
298298

299299
# Test first user's data
300-
login_3.click()
300+
login_3.click(delay=CLICK_DELAY)
301301
user_data_div = self.page.wait_for_selector(
302302
"#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]"
303303
)
@@ -313,13 +313,81 @@ def test_component_use_user_data_with_default(self):
313313
)
314314

315315
# Attempt to clear data
316-
clear.click()
316+
clear.click(delay=CLICK_DELAY)
317317
sleep(0.25)
318318
user_data_div = self.page.wait_for_selector(
319319
"#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]"
320320
)
321321
assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content()
322322

323+
@navigate_to_page("/")
324+
def test_component_use_auth(self):
325+
uuid = self.page.wait_for_selector("#use-auth").get_attribute("data-uuid")
326+
assert len(uuid) == 36
327+
328+
self.page.wait_for_selector("#use-auth .login").click(delay=CLICK_DELAY)
329+
330+
# Wait for #use-auth[data-username="user_4"] to appear
331+
self.page.wait_for_selector("#use-auth[data-username='user_4']")
332+
self.page.wait_for_selector(f"#use-auth[data-uuid='{uuid}']")
333+
334+
# Press disconnect and wait for #use-auth[data-uuid=...] to disappear
335+
self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY)
336+
expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0)
337+
338+
# Double check that the same user is logged in
339+
self.page.wait_for_selector("#use-auth[data-username='user_4']")
340+
341+
# Press logout and wait for #use-auth[data-username="AnonymousUser"] to appear
342+
self.page.wait_for_selector("#use-auth .logout").click(delay=CLICK_DELAY)
343+
self.page.wait_for_selector("#use-auth[data-username='AnonymousUser']")
344+
345+
# Press disconnect and wait for #use-auth[data-uuid=...] to disappear
346+
self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY)
347+
expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0)
348+
349+
# Double check that the user stayed logged out
350+
self.page.wait_for_selector("#use-auth[data-username='AnonymousUser']")
351+
352+
@navigate_to_page("/")
353+
def test_component_use_auth_no_rerender(self):
354+
uuid = self.page.wait_for_selector("#use-auth-no-rerender").get_attribute("data-uuid")
355+
assert len(uuid) == 36
356+
357+
self.page.wait_for_selector("#use-auth-no-rerender .login").click(delay=CLICK_DELAY)
358+
359+
# Make sure #use-auth[data-username="user_5"] does not appear
360+
with pytest.raises(TimeoutError):
361+
self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']", timeout=1)
362+
363+
# Press disconnect and see if #use-auth[data-username="user_5"] appears
364+
self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY)
365+
self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']")
366+
367+
# Press logout and make sure #use-auth[data-username="AnonymousUser"] does not appear
368+
with pytest.raises(TimeoutError):
369+
self.page.wait_for_selector("#use-auth-no-rerender[data-username='AnonymousUser']", timeout=1)
370+
371+
# Press disconnect and see if #use-auth[data-username="AnonymousUser"] appears
372+
self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY)
373+
374+
@navigate_to_page("/")
375+
def test_component_use_rerender(self):
376+
initial_uuid = self.page.wait_for_selector("#use-rerender").get_attribute("data-uuid")
377+
assert len(initial_uuid) == 36
378+
379+
rerender_button = self.page.wait_for_selector("#use-rerender button")
380+
rerender_button.click(delay=CLICK_DELAY)
381+
382+
# Wait for #use-rerender[data-uuid=...] to disappear
383+
expect(self.page.locator(f"#use-rerender[data-uuid='{initial_uuid}']")).to_have_count(0)
384+
385+
# Find the new #use-rerender[data-uuid=...]
386+
self.page.wait_for_selector("#use-rerender")
387+
new_uuid = self.page.wait_for_selector("#use-rerender").get_attribute("data-uuid")
388+
assert len(new_uuid) == 36
389+
assert new_uuid != initial_uuid
390+
323391
###################
324392
# Prerender Tests #
325393
###################

0 commit comments

Comments
 (0)
Please sign in to comment.