Skip to content

Commit 11c4f24

Browse files
task/CDD-1379-page-previews
Enable page previews to the configured frontend URL
1 parent fdb88c3 commit 11c4f24

File tree

23 files changed

+2198
-300
lines changed

23 files changed

+2198
-300
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# CDD-1379 - Page Previews
2+
3+
**Date:** 2026-02-27
4+
5+
**Ticket:** https://ukhsa.atlassian.net/browse/CDD-1379?search_id=055fe61d-bee9-48d9-80bc-ffb0f1c26b76&referrer=quick-find
6+
7+
**Authors:** Jean-Pierre Fouche
8+
9+
**Impact:** Affects all pages - broad testing required
10+
11+
**Testing:** Comprehensive unit tests supplied. UAT needed.
12+
13+
14+
## Summary
15+
16+
Allow editors of headless composite pages to click a **Preview** button that immediately redirects them to the external frontend application, rather than opening the built-in Wagtail iframe preview. Preview URLs include a short-lived signed token so the frontend can safely fetch draft content from the CMS.
17+
18+
## Workflow
19+
20+
- Editors see a custom "Preview" button in the CMS if the page type allows previews.
21+
- When clicked, the CMS generates a short-lived, signed token and redirects the editor’s browser to the frontend preview URL with this token.
22+
- The frontend uses the token to securely fetch the latest draft content from the CMS via a special API endpoint.
23+
- The API validates the token (including expiry and page ID) before returning draft content.
24+
- Preview enablement is controlled by a flag on each page type.
25+
- The system avoids Wagtail’s built-in iframe preview, using external redirects and API calls for a secure, modern preview experience.
26+
- Security is enforced by short token lifetimes, HMAC signing, and requiring tokens in Authorization headers for API access.
27+
28+
## Architecture
29+
30+
### Component Flow Diagram
31+
32+
```mermaid
33+
sequenceDiagram
34+
participant Browser as Editor Browser
35+
participant CMS as Wagtail Admin (CMS)
36+
participant API as Django CMS API
37+
participant FE as Next.js Frontend
38+
39+
Browser->>CMS: Load page editor
40+
CMS-->>Browser: Render Preview button (href=/admin/preview-to-frontend/{page_id}/)
41+
Note right of Browser: Preview button visible
42+
Browser->>CMS: Click Preview
43+
Browser->>API: GET /admin/preview-to-frontend/{page_id}
44+
API-->>API: Build signed token + frontend preview URL
45+
API-->>Browser: 302 Location: /preview?slug=...&t=...
46+
Browser->>FE: Follow 302 to frontend preview URL
47+
FE->>API: GET /api/drafts/{id} (Authorization: Bearer <token>)
48+
API-->>FE: draft JSON
49+
FE-->>Browser: Rendered preview page
50+
```
51+
52+
### Security
53+
54+
- **Token TTL**: 120-second expiry limits exposure window
55+
- **HMAC signing**: Tokens cryptographically signed, cannot be forged
56+
- **Salt isolation**: Preview tokens use dedicated salt, separate from session tokens
57+
- **Bearer vs querystring**: Token transmitted in Authorization header to API (reduces logging exposure), though initially passed via querystring in redirect (acceptable for short-lived tokens)
58+
- **Prevention of replay attacks**: Each token includes `iat` timestamp and specific `page_id`, limiting reuse scope
59+
60+
## Environment Variables
61+
62+
Set these up in an environment file (such as env.local)
63+
(These are defined with default values in default.py and local.py)
64+
65+
```bash
66+
PAGE_PREVIEWS_ENABLED = False # Allows the server to disable or enable page previews
67+
PAGE_PREVIEWS_FRONTEND_BASE_URL = 'http://localhost:3000' # The base URL for the front-end application. Allows the CMS to send the browser to the frontend on the click of a button.
68+
PAGE_PREVIEWS_FRONTEND_URL_TEMPLATE = "http://localhost:3000/preview?slug={slug}&t={token}" # The format of the URL redirect that will be sent to the Front End
69+
PAGE_PREVIEWS_TOKEN_TTL_SECONDS = 86400 # The front end receives a presigned url. This setting defines the token expiry window. It is recommended to keep this as low as possible, and can possibly be set to as low as 60 seconds, the time it takes for the front end to render the page. Default is 120 seconds.
70+
PAGE_PREVIEWS_TOKEN_SALT = 'preview-token' # Salt string - adds an extra layer of security into the generation of the token.
71+
```
72+
73+
## Files Changed
74+
75+
| File Name | Purpose of Change | Process (Inputs, Process, Outputs, Example) |
76+
|-----------|------------------|---------------------------------------------|
77+
| cms/common/models.py | Added preview support and improved related link logic for common pages, enabling editors to preview unpublished changes and manage related links more flexibly. | **Input:** Page instance.<br>**Process:** Adds preview fields and related link handling.<br>**Output:** CommonPage with preview and links.<br>**Example:** Input: CommonPage; Output: Previewable CommonPage. |
78+
| cms/composite/models.py | Enhanced composite pages with preview, pagination, and related link support, allowing editors to preview drafts and control page navigation. | **Input:** CompositePage instance.<br>**Process:** Adds preview, pagination, and related link fields.<br>**Output:** CompositePage with preview and pagination.<br>**Example:** Input: CompositePage; Output: Previewable CompositePage. |
79+
| cms/dashboard/models.py | Centralized preview enablement and shared logic for all dashboard pages, standardizing how preview is toggled and managed across page types. | **Input:** Dashboard page instance.<br>**Process:** Adds preview flags and shared logic.<br>**Output:** Dashboard pages with preview toggle.<br>**Example:** Input: UKHSAPage; Output: Preview-enabled UKHSAPage. |
80+
| cms/dashboard/serializers.py | Extended serializers to support draft and published page data for the API, ensuring frontend receives correct structure for previews. | **Input:** Page instance.<br>**Process:** Serializes fields for frontend preview.<br>**Output:** JSON page data.<br>**Example:** Input: Draft page; Output: `{ "id": 123, "title": "COVID-19" }` |
81+
| cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_preview.html | New template for the Preview button in Wagtail admin, providing a clear call-to-action for editors to preview content externally. | **Input:** Button context (label, url, etc).<br>**Process:** Renders button HTML.<br>**Output:** Preview button in admin.<br>**Example:** Input: label="Preview", url="/admin/preview/1"; Output: Button HTML. |
82+
| cms/dashboard/views.py | Added secure redirect logic to generate signed preview tokens and forward editors to the frontend preview, enforcing permissions and token expiry. | **Input:** GET `/admin/preview-to-frontend/{page_id}/`.<br>**Process:** Checks permissions, signs token, builds URL.<br>**Output:** 302 redirect to frontend.<br>**Example:** Input: Page ID 123; Output: Redirect to preview URL. |
83+
| cms/dashboard/viewsets.py | Implemented API endpoint to serve draft content to the frontend, validating signed tokens and supporting secure preview of unpublished changes. | **Input:** GET `/api/drafts/{id}` with Bearer token.<br>**Process:** Validates token, fetches draft.<br>**Output:** JSON draft page.<br>**Example:** Input: Bearer token for page 123; Output: Draft JSON. |
84+
| cms/dashboard/wagtail_hooks.py | Registered preview button, admin URLs, and menu actions in Wagtail, integrating the preview workflow into the CMS UI and admin menus. | **Input:** Page edit context.<br>**Process:** Adds preview button/action, registers redirect endpoint.<br>**Output:** Preview button and redirect URL.<br>**Example:** Input: Editor clicks Preview; Output: Redirect to frontend. |
85+
| cms/home/models/landing_page.py | Enabled preview and related link support for landing pages, so editors can review unpublished changes and manage links. | **Input:** LandingPage instance.<br>**Process:** Adds preview and related link fields.<br>**Output:** LandingPage with preview.<br>**Example:** Input: LandingPage; Output: Previewable LandingPage. |
86+
| cms/topic/models.py | Enabled preview and related link support for topic pages, improving editorial workflow for draft content and navigation. | **Input:** TopicPage instance.<br>**Process:** Adds preview and related link fields.<br>**Output:** TopicPage with preview.<br>**Example:** Input: TopicPage; Output: Previewable TopicPage. |
87+
| config.py | Added and documented preview/token settings and environment variables, ensuring correct configuration for secure preview flows. | **Input:** Env variables.<br>**Process:** Loads/sets preview config.<br>**Output:** Config values for preview/token.<br>**Example:** Input: PAGE_PREVIEWS_ENABLED; Output: True/False. |
88+
| metrics/api/settings/default.py | Set default preview/token and API settings, providing baseline security and feature toggles for preview functionality. | **Input:** Default settings.<br>**Process:** Sets preview/token defaults.<br>**Output:** Default config for preview/token.<br>**Example:** Input: PAGE_PREVIEWS_TOKEN_TTL_SECONDS; Output: 120. |
89+
| metrics/api/settings/local.py | Overrode preview/token settings for local development, making it easier to test preview features in dev environments. | **Input:** Local settings.<br>**Process:** Overrides preview config.<br>**Output:** Local config for preview/token.<br>**Example:** Input: PAGE_PREVIEWS_ENABLED; Output: True. |
90+
| scripts/_quality.sh | Bug fix - return replaces exit, thus keeping the current shell open. |
91+

cms/common/models.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,6 @@ class CommonPage(UKHSAPage):
5353

5454
objects = CommonPageManager()
5555

56-
@classmethod
57-
def is_previewable(cls) -> bool:
58-
"""Returns False. Since this is a headless CMS the preview panel is not supported"""
59-
return False
60-
6156

6257
class CommonPageRelatedLink(UKHSAPageRelatedLink):
6358
page = ParentalKey(

cms/composite/models.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,6 @@ class CompositePage(UKHSAPage):
8686

8787
objects = CompositePageManager()
8888

89-
@classmethod
90-
def is_previewable(cls) -> bool:
91-
"""Returns False. Since this is a headless CMS the preview panel is not supported"""
92-
return False
93-
9489
@property
9590
def last_updated_at(self) -> datetime.datetime:
9691
"""Takes the most recent update of this page and any of its children

cms/dashboard/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22
from decimal import Decimal
3+
from typing import override
34

45
from django.core.exceptions import ValidationError
56
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -43,6 +44,8 @@ class UKHSAPage(Page):
4344
4445
"""
4546

47+
custom_preview_enabled: bool = True
48+
4649
body = RichTextField(features=AVAILABLE_RICH_TEXT_FEATURES)
4750
seo_change_frequency = models.IntegerField(
4851
verbose_name="SEO change frequency",
@@ -98,6 +101,11 @@ class UKHSAPage(Page):
98101
class Meta:
99102
abstract = True
100103

104+
@override
105+
def is_previewable(self) -> bool:
106+
"""Disable built-in Wagtail preview for all headless dashboard pages."""
107+
return False
108+
101109
def _raise_error_if_slug_not_unique(self) -> None:
102110
"""Compares the provided slug against all pages to confirm the slug's `uniqueness`
103111
this is against all pages and not just siblings, which is the default behavior of wagtail.

cms/dashboard/serializers.py

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,7 @@
1-
from collections import OrderedDict
2-
3-
from rest_framework import serializers
41
from wagtail.api.v2.views import PageSerializer
5-
from wagtail.models import Page
62

73
from cms.dashboard.fields import ListablePageParentField
84

9-
PAGE_HAS_NO_DRAFTS = (
10-
"Page has no unpublished changes. Use the `api/pages/` for live pages instead."
11-
)
12-
13-
14-
class CMSDraftPagesSerializer(PageSerializer):
15-
class Meta:
16-
model = Page
17-
fields = "__all__"
18-
19-
def to_representation(self, instance: Page) -> OrderedDict:
20-
"""Provides a representation of the serialized instance
21-
22-
Notes:
23-
This provides some additional logic to ensure
24-
the `instance` being serialized has unpublished changes.
25-
If not, then the serializer is invalidated
26-
27-
Args:
28-
instance: The `Page` model being serialized
29-
30-
Returns:
31-
A dict representation of the serialized instance
32-
33-
"""
34-
if not instance.has_unpublished_changes:
35-
raise serializers.ValidationError({"error_message": PAGE_HAS_NO_DRAFTS})
36-
37-
return super().to_representation(instance=instance)
38-
395

406
class ListablePageSerializer(PageSerializer):
417
parent = ListablePageParentField(read_only=True)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{% load wagtailadmin_tags %}
2+
{% if url %}
3+
<a class="button{% if classname %} {{ classname }}{% endif %}" href="{{ url }}" target="_blank" rel="noopener noreferrer">{% if icon_name %}{% icon name=icon_name %}{% endif %}{{ label }}</a>
4+
{% else %}
5+
<button type="submit" name="{{ name }}" value="{{ label }}" class="button{% if classname %} {{ classname }}{% endif %}">{% if icon_name %}{% icon name=icon_name %}{% endif %}{{ label }}</button>
6+
{% endif %}

cms/dashboard/views.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,135 @@
1+
from datetime import timedelta
2+
from urllib.parse import urlencode, urlsplit, urlunsplit
3+
4+
from django.conf import settings
5+
from django.core.exceptions import PermissionDenied
16
from django.core.handlers.wsgi import WSGIRequest
7+
from django.core.signing import dumps
28
from django.http import JsonResponse
9+
from django.shortcuts import get_object_or_404, redirect
10+
from django.utils import timezone
11+
from django.views import View
312
from wagtail.admin.views.chooser import BrowseView
13+
from wagtail.models import Page
14+
15+
# Token salt for preview tokens; configurable via Django settings to avoid
16+
# hard-coded strings in code.
17+
PAGE_PREVIEWS_TOKEN_SALT = getattr(
18+
settings, "PAGE_PREVIEWS_TOKEN_SALT", "preview-token"
19+
)
20+
21+
22+
class PreviewToFrontendRedirectView(View):
23+
"""Generate a signed preview token and redirect to the frontend.
24+
25+
This view is intentionally simple: it performs a permission check on the
26+
requested page, builds a small payload, signs it using Django's signing
27+
utilities and then redirects the browser to the frontend with the token as
28+
a query parameter. The frontend is responsible for validating the token
29+
and fetching any draft content.
30+
31+
Attributes:
32+
PREVIEW_TOKEN_TTL_SECONDS: Token lifetime in seconds (configurable via
33+
PAGE_PREVIEWS_TOKEN_TTL_SECONDS setting, defaults to 120)
34+
"""
35+
36+
# token lifetime in seconds (configurable via settings)
37+
PREVIEW_TOKEN_TTL_SECONDS = getattr(
38+
settings,
39+
"PAGE_PREVIEWS_TOKEN_TTL_SECONDS",
40+
120,
41+
)
42+
43+
@staticmethod
44+
def _canonicalise_preview_url(
45+
*, raw_url: str, slug: str, token: str, page_id: int
46+
) -> str:
47+
"""Return preview URL with canonical query params.
48+
49+
The query string is always normalised to `slug`, `t`, and `page_id` so stale or
50+
legacy template parameters (e.g. `draft=true`, `slug_name`) are not propagated to the frontend.
51+
"""
52+
parts = urlsplit(raw_url)
53+
query = urlencode({"slug": slug, "t": token, "page_id": page_id})
54+
return urlunsplit(
55+
(parts.scheme, parts.netloc, parts.path, query, parts.fragment)
56+
)
57+
58+
@staticmethod
59+
def build_route_slug(*, page: Page) -> str:
60+
"""Return route-style slug path matching frontend route shape.
61+
62+
For nested pages this returns paths like `parent/child` (matching
63+
`html_url` path semantics) rather than a leaf-only slug.
64+
"""
65+
route_path = ""
66+
try:
67+
_, _, page_path = page.get_url_parts(request=None)
68+
route_path = (page_path or "").strip("/")
69+
except (AttributeError, TypeError, ValueError):
70+
route_path = ""
71+
72+
return route_path or str(page.slug)
73+
74+
def get(self, request, pk):
75+
"""Handle GET request to generate preview token and redirect to frontend.
76+
77+
Args:
78+
request: The HTTP request object
79+
pk: Primary key of the Page to preview
80+
81+
Returns:
82+
HttpResponseRedirect: Redirect to frontend preview URL with signed token
83+
84+
Raises:
85+
Http404: If page with given pk does not exist
86+
PermissionDenied: If user lacks edit permission for the page
87+
"""
88+
page = get_object_or_404(Page, pk=pk).specific
89+
90+
perms = page.permissions_for_user(request.user)
91+
if not perms.can_edit():
92+
raise PermissionDenied
93+
94+
now = timezone.now()
95+
payload = {
96+
"page_id": page.pk,
97+
"editor_id": request.user.pk if request.user.is_authenticated else None,
98+
"iat": int(now.timestamp()),
99+
"exp": int(
100+
(now + timedelta(seconds=self.PREVIEW_TOKEN_TTL_SECONDS)).timestamp()
101+
),
102+
}
103+
104+
token = dumps(payload, salt=PAGE_PREVIEWS_TOKEN_SALT)
105+
106+
# Build the frontend URL using a configurable template. The template
107+
# should include placeholders for `{slug}` and `{token}`.
108+
# A default
109+
# value is provided to preserve previous behaviour.
110+
# See docs/environment_variables.md for PAGE_PREVIEWS_FRONTEND_URL_TEMPLATE format.
111+
template = getattr(
112+
settings,
113+
"PAGE_PREVIEWS_FRONTEND_URL_TEMPLATE",
114+
"http://localhost:3000/preview?slug={slug}&t={token}",
115+
)
116+
117+
route_slug = self.build_route_slug(page=page)
118+
119+
frontend_url = template.format(
120+
page_id=page.pk,
121+
slug_name=page.slug,
122+
slug=route_slug,
123+
token=token,
124+
)
125+
frontend_url = self._canonicalise_preview_url(
126+
raw_url=frontend_url,
127+
slug=route_slug,
128+
token=token,
129+
page_id=page.pk,
130+
)
131+
132+
return redirect(frontend_url)
4133

5134

6135
class LinkBrowseView(BrowseView):

0 commit comments

Comments
 (0)