Skip to content
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
<!--attr-end-->

<!--
Types of changes are to be listed in this order
Using the following categories, list your changes in this order:
- "Added" for new features.
- "Changed" for changes in existing functionality.
- "Deprecated" for soon-to-be removed features.
Expand All @@ -22,7 +22,9 @@ Types of changes are to be listed in this order

## [Unreleased]

- Nothing (yet)
### Added

- `auth_required` decorator to prevent your components from rendered to unauthenticated users.

## [1.1.0] - 2022-07-01

Expand Down
115 changes: 115 additions & 0 deletions docs/features/decorators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
## Auth Required

You can limit access to a component to users with a specific `auth_attribute` by using this decorator.

By default, this decorator checks if the user is logged in, and his/her account has not been deactivated.

Common uses of this decorator are to hide components from [`AnonymousUser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.AnonymousUser), or render a component only if the user [`is_staff`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_staff) or [`is_superuser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser).

This decorator can be used with or without parentheses.

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html

@component
@auth_required
def my_component():
return html.div("I am logged in!")
```

??? example "See Interface"

<font size="4">**Parameters**</font>

| Name | Type | Description | Default |
| --- | --- | --- | --- |
| auth_attribute | `str` | The value to check within the user object. This is checked in the form of `UserModel.<auth_attribute>`. | `#!python "is_active"` |
| fallback | `ComponentType`, `VdomDict`, `None` | The `component` or `idom.html` snippet to render if the user is not authenticated. | `None` |

<font size="4">**Returns**</font>

| Type | Description |
| --- | --- |
| `Component` | An IDOM component. |
| `VdomDict` | An `idom.html` snippet. |
| `None` | No component render. |

??? question "How do I render a different component if authentication fails?"

You can use a component with the `fallback` argument, as seen below.

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html

@component
def my_component_fallback():
return html.div("I am NOT logged in!")

@component
@auth_required(fallback=my_component_fallback)
def my_component():
return html.div("I am logged in!")
```

??? question "How do I render a simple `idom.html` snippet if authentication fails?"

You can use a `idom.html` snippet with the `fallback` argument, as seen below.

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html

@component
@auth_required(fallback=html.div("I am NOT logged in!"))
def my_component():
return html.div("I am logged in!")
```

??? question "How can I check if a user `is_staff`?"

You can set the `auth_attribute` to `is_staff`, as seen blow.

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html


@component
@auth_required(auth_attribute="is_staff")
def my_component():
return html.div("I am logged in!")
```

??? question "How can I check for a custom attribute?"

You will need to be using a [custom user model](https://docs.djangoproject.com/en/dev/topics/auth/customizing/#specifying-a-custom-user-model) within your Django instance.

For example, if your user model has the field `is_really_cool` ...

```python
from django.contrib.auth.models import AbstractBaseUser

class CustomUserModel(AbstractBaseUser):
@property
def is_really_cool(self):
return True
```

... then you would do the following within your decorator:

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html

@component
@auth_required(auth_attribute="is_really_cool")
def my_component():
return html.div("I am logged in!")
```
2 changes: 1 addition & 1 deletion docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
}

.md-typeset :is(.admonition, details) {
margin: 1em 0;
margin: 0.55em 0;
}
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ nav:
- Exclusive Features:
- Components: features/components.md
- Hooks: features/hooks.md
- Decorators: features/decorators.md
- ORM: features/orm.md
- Template Tag: features/templatetag.md
- Settings: features/settings.md
Expand Down
15 changes: 11 additions & 4 deletions src/django_idom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from . import components, hooks
from .websocket.consumer import IdomWebsocket
from .websocket.paths import IDOM_WEBSOCKET_PATH
from django_idom import components, decorators, hooks, types
from django_idom.types import IdomWebsocket
from django_idom.websocket.paths import IDOM_WEBSOCKET_PATH


__version__ = "1.1.0"
__all__ = ["IDOM_WEBSOCKET_PATH", "IdomWebsocket", "hooks", "components"]
__all__ = [
"IDOM_WEBSOCKET_PATH",
"types",
"IdomWebsocket",
"hooks",
"components",
"decorators",
]
44 changes: 44 additions & 0 deletions src/django_idom/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from functools import wraps
from typing import Callable, Union

from idom.core.types import ComponentType, VdomDict

from django_idom.hooks import use_websocket


def auth_required(
component: Union[Callable, None] = None,
auth_attribute: str = "is_active",
fallback: Union[ComponentType, VdomDict, None] = None,
) -> Callable:
"""If the user passes authentication criteria, the decorated component will be rendered.
Otherwise, the fallback component will be rendered.

This decorator can be used with or without parentheses.

Args:
auth_attribute: The value to check within the user object.
This is checked in the form of `UserModel.<auth_attribute>`.
fallback: The component or VDOM (`idom.html` snippet) to render if the user is not authenticated.
"""

def decorator(component):
@wraps(component)
def _wrapped_func(*args, **kwargs):
websocket = use_websocket()

if getattr(websocket.scope["user"], auth_attribute):
return component(*args, **kwargs)

if callable(fallback):
return fallback(*args, **kwargs)
return fallback

return _wrapped_func

# Return for @authenticated(...)
if component is None:
return decorator

# Return for @authenticated
return decorator(component)
11 changes: 2 additions & 9 deletions src/django_idom/hooks.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Optional, Type, Union
from typing import Dict, Type, Union

from idom.backend.types import Location
from idom.core.hooks import Context, create_context, use_context


@dataclass
class IdomWebsocket:
scope: dict
close: Callable[[Optional[int]], Awaitable[None]]
disconnect: Callable[[int], Awaitable[None]]
view_id: str
from django_idom.types import IdomWebsocket


WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context(
Expand Down
10 changes: 10 additions & 0 deletions src/django_idom/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from dataclasses import dataclass
from typing import Awaitable, Callable, Optional


@dataclass
class IdomWebsocket:
scope: dict
close: Callable[[Optional[int]], Awaitable[None]]
disconnect: Callable[[int], Awaitable[None]]
view_id: str
3 changes: 2 additions & 1 deletion src/django_idom/websocket/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
from idom.core.serve import serve_json_patch

from django_idom.config import IDOM_REGISTERED_COMPONENTS
from django_idom.hooks import IdomWebsocket, WebsocketContext
from django_idom.hooks import WebsocketContext
from django_idom.types import IdomWebsocket


_logger = logging.getLogger(__name__)
Expand Down
33 changes: 33 additions & 0 deletions tests/test_app/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,36 @@ def django_js():
),
idom.html.hr(),
)


@idom.component
@django_idom.decorators.auth_required(
fallback=idom.html.div(
{"id": "unauthorized-user-fallback"},
"unauthorized_user: Success",
idom.html.hr(),
)
)
def unauthorized_user():
return idom.html.div(
{"id": "unauthorized-user"},
"unauthorized_user: Fail",
idom.html.hr(),
)


@idom.component
@django_idom.decorators.auth_required(
auth_attribute="is_anonymous",
fallback=idom.html.div(
{"id": "authorized-user-fallback"},
"authorized_user: Fail",
idom.html.hr(),
),
)
def authorized_user():
return idom.html.div(
{"id": "authorized-user"},
"authorized_user: Success",
idom.html.hr(),
)
2 changes: 2 additions & 0 deletions tests/test_app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ <h1>IDOM Test Page</h1>
<div>{% component "test_app.components.use_location" %}</div>
<div>{% component "test_app.components.django_css" %}</div>
<div>{% component "test_app.components.django_js" %}</div>
<div>{% component "test_app.components.unauthorized_user" %}</div>
<div>{% component "test_app.components.authorized_user" %}</div>
</body>

</html>
19 changes: 19 additions & 0 deletions tests/test_app/tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from channels.testing import ChannelsLiveServerTestCase
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
Expand Down Expand Up @@ -69,6 +70,24 @@ def test_static_js(self):
element = self.driver.find_element_by_id("django-js")
self.assertEqual(element.get_attribute("data-success"), "true")

def test_unauthorized_user(self):
self.assertRaises(
NoSuchElementException,
self.driver.find_element_by_id,
"unauthorized-user",
)
element = self.driver.find_element_by_id("unauthorized-user-fallback")
self.assertIsNotNone(element)

def test_authorized_user(self):
self.assertRaises(
NoSuchElementException,
self.driver.find_element_by_id,
"authorized-user-fallback",
)
element = self.driver.find_element_by_id("authorized-user")
self.assertIsNotNone(element)


def make_driver(page_load_timeout, implicit_wait_timeout):
options = webdriver.ChromeOptions()
Expand Down