Skip to content

Commit b9af25d

Browse files
authored
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

Diff for: .github/workflows/test-python.yml

+17
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

Diff for: CHANGELOG.md

+10-1
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

Diff for: docs/examples/python/use_auth.py

+23
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+
)

Diff for: docs/examples/python/use_rerender.py

+15
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"))

Diff for: docs/includes/auth-middleware-stack.md

+3
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+
```

Diff for: docs/src/learn/add-reactpy-to-a-django-project.md

+1-3
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

Diff for: docs/src/reference/hooks.md

+83-6
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. |

Diff for: docs/src/reference/settings.md

+33-9
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`

Diff for: docs/src/reference/template-tag.md

+1-1
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

Diff for: pyproject.toml

+11
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 <<< #

Diff for: src/js/src/components.ts

+25-1
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+
}

Diff for: src/js/src/index.ts

+1-1
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";

Diff for: src/js/src/types.ts

+7
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+
}

Diff for: src/reactpy_django/auth/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)