Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ current directory). Point it at PostgreSQL by setting `DATABASE_URL`:
export DATABASE_URL=postgres://user:pass@localhost:5432/workbench
```

The Django admin (`/admin/`) is available to administrators for creating
projects and managing membership.
The Django admin defaults to `/control/` and is available to administrators for
creating projects and managing membership. Set `ACES_WORKBENCH_ADMIN_PATH` to
use a deployment-specific admin path.

## Documentation

Expand Down Expand Up @@ -81,7 +82,7 @@ rate-limits password-reset and invitation requests. `aces-workbench doctor`
reports the live posture. `aces-workbench serve` is the development server and
runs in debug mode; host with the container path in
[`docs/deployment.md`](docs/deployment.md). Keep the Django admin
(`/admin/`) restricted to administrators.
restricted to administrators.

## CLI

Expand All @@ -95,7 +96,7 @@ aces-workbench manage <command> [...] # any Django management command
```

Administrators create projects and invite members from the Django admin
(`/admin/`); import a scenario pack's ATLAS technique projection with
(`/control/` by default); import a scenario pack's ATLAS technique projection with
`aces-workbench import <pack-path> --project <slug>`.

## Development
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/33.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Changed

- Restored the authenticated review dashboard experience with branded chrome,
overview metrics, tactic bars, progression cards, richer module tiles,
clickable review rows, safer admin routing, and polished public/auth flows.
3 changes: 2 additions & 1 deletion docs/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ existence in exactly two ways, both driven by an administrator:

## Inviting someone

In the Django admin (`/admin/`):
In the Django admin (`/control/` by default, unless `ACES_WORKBENCH_ADMIN_PATH`
is set):

1. Go to **Invitations → Add**.
2. Enter the person's **email**, the **project**, and their **role**.
Expand Down
153 changes: 153 additions & 0 deletions docs/decisions/adrs/001-front-end-product-readiness-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# ADR-001: Front-End Product Readiness Boundaries

Date: 2026-07-12

Status: Accepted

## Context

Issue #33 is a cross-cutting product-readiness repair. It touches public entry,
authenticated review chrome, dashboard density, branding, cookie notice, admin
exposure, accessibility, forms, error pages, and print output.

The repository already has important boundaries that must remain intact:

- ACES packs and review bundles are the source of scenario content; the
workbench database owns collaboration state only.
- Project membership is the authorization boundary, enforced in
`workbench.access` and `workbench.authz`.
- Account creation is invite-only through the existing accounts app.
- Security posture is centralized in Django settings, middleware, CSP,
django-axes, django-ratelimit, CSRF, and the `doctor` command.
- `prototype/` is non-authoritative seed material; production UI belongs in the
Django views, templates, and static assets.

## Decision

The front-end overhaul remains a server-rendered Django application. It must
restore the dashboard-quality experience through existing views, templates, and
static assets rather than introducing a separate client app, a duplicate domain
schema, or persisted reporting tables.

Dashboard metrics, tactic bars, progression cards, digest/integrity details, and
print output are derived from the existing `Revision` object graph
(`tactics`, `steps`, `techniques`, `evidence`, `metadata`,
`content_digest`). They are presentation context, not new persistence.

Brand, metadata, legal chrome, footer links, and default page structure are
centralized in `workbench/base.html` and static files under
`workbench/static/workbench/`. Assets and fonts must be same-origin static
assets; do not add CDN-hosted fonts, scripts, or images that would weaken the
current CSP and offline install story.

The Django admin is not a public landing target. It is mounted through one
normalized setting such as `ACES_WORKBENCH_ADMIN_PATH`, with the root URL
configuration, CSP admin exclusion, documentation, `doctor`, and tests all
deriving from the same value. The default must not be the conventional
`admin/`. Treat the admin path as noise reduction, not as a secret or a
substitute for staff authentication and deployment-layer restriction.

Public entry remains invite-only: anonymous users get a clear sign-in path,
privacy/cookie links, and product context, but not open registration. Staff-only
admin navigation may be shown after authentication using Django's `admin:index`
URL, never as a public call to action.

Cookie notice is informational for the current cookie/storage surface:
session/CSRF cookies plus the local theme preference in `localStorage`. Do not
add analytics or non-essential storage as part of this issue. If future
non-essential storage is introduced, consent categories belong behind one
central legal/chrome seam rather than scattered per-template checks.

Custom 404, 429, and 500 pages use the same branded base where safe and must not
leak exception details. The existing 429 rate-limit handling remains canonical.

Print/PDF support is HTML-first: use print styles and, if needed, a print-mode
variant of the revision overview. Do not add a server-side PDF renderer or
headless browser dependency unless a later requirement explicitly needs it.

## Required Cross-Cutting Contracts

Security gates the implementation must pass:

- Authentication: reuse Django auth views, the custom email user model,
password validators, django-axes, and django-ratelimit. Do not create open
registration or parallel login/reset flows.
- Authorization: use `login_required`, `access.member_project`,
`access.scoped_revision`, and `authz.can_contribute`; hiding links is not
authorization.
- CSRF and destructive actions: account deletion and all state-changing forms
remain POST plus CSRF. Add a confirmation step for account deletion without
bypassing the existing export/delete account contract.
- Admin exposure: validate the configured admin path as a relative URL path
without query strings, fragments, absolute URLs, backslashes, or `..`
segments. Keep the path out of public anonymous chrome.
- CSP: keep scripts same-origin plus nonce. No inline event handlers, inline
style attributes, or remote scripts/fonts. If a pre-paint theme script remains
inline, it must carry the existing per-request CSP nonce.
- Cookie/storage notice: disclose session, CSRF, and theme-preference storage.
Any banner dismissal storage must itself be covered by the notice.
- Error envelopes: UI errors render branded templates; API upload errors keep
the existing JSON `{"detail": ...}` shape and must not expose secrets or
stack traces.
- OS/runtime exposure: new operational knobs belong in environment variables
read by settings, not command-line arguments. Admin path configuration is not
a secret; do not print secrets, tokens, or configured secret values.

Canonical incumbents to build on:

- Settings/env helpers in `aces_scenario_workbench.settings`.
- Readiness reporting in `workbench.management.commands.doctor`.
- URL names from `accounts.urls`, `workbench.urls`, and Django `admin:index`.
- Account forms/views in `accounts.forms` and `accounts.views`.
- Project authorization helpers in `workbench.access` and `workbench.authz`.
- Collaboration context/actions in `workbench.collab`.
- Projection parsing/import rules in `workbench.ingest`.
- Existing test suites for hardening, auth, accounts, review views, collab,
ingest, CLI, and onboarding.
- Local gates: `make check`, `uv run ruff check .`,
`uv run ruff format --check .`, and `uv run pytest`.

Extensibility seams:

- Admin mount path: one normalized setting used by URLs, CSP, docs, doctor, and
tests.
- Legal chrome: one footer/cookie-notice surface for privacy, terms/cookie copy,
and any future consent categories.
- Brand metadata: base-template blocks/defaults for title, description,
Open Graph/Twitter text, theme color, and icon assets.
- Tier display: one CSS token/data-attribute vocabulary for `quick`,
`intermediate`, and `advanced`; do not encode colors independently in each
template.
- Print mode: a revision-overview rendering seam that can later feed a real PDF
service if required.

## Non-Goals

- No rewrite to a SPA or client-side routing architecture.
- No model rename or migration from `Step`/`path_step` to "Module" as part of
front-end polish; choose display labels deliberately while preserving storage
and URL contracts.
- No new ingestion schema, review-state workflow, role model, or authorization
hierarchy.
- No analytics, tracking pixels, third-party asset CDNs, or non-essential
cookies/storage.
- No server-side PDF generation dependency.
- No broad refactor of domain services, admin models, or migrations merely to
support visual polish.

## Anti-Patterns To Avoid

- Linking anonymous users to the admin or treating an obscured admin path as the
only protection.
- Recomputing authorization in templates instead of using the server-side
helpers.
- Duplicating review summary data in new tables when it can be derived from a
revision.
- Adding a second form-validation layer in templates or JavaScript that diverges
from Django forms and model choices.
- Hard-coding `/admin/`, tier colors, legal links, or metadata in multiple
templates.
- Using inline handlers, unsafe-inline CSP, remote fonts, or remote UI scripts
for convenience.
- Rendering false accessibility state from the server, such as a theme toggle
`aria-pressed` value that can only be corrected later by JavaScript.
5 changes: 4 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Set these in the container/host environment for a hosted deployment:
| `ACES_WORKBENCH_ALLOWED_HOSTS` | comma-separated hostnames |
| `ACES_WORKBENCH_CSRF_TRUSTED_ORIGINS` | comma-separated origins for the public URL |
| `ACES_WORKBENCH_CACHE_URL` | shared cache for rate limiting behind multiple workers, e.g. `redis://host:6379/0` |
| `ACES_WORKBENCH_ADMIN_PATH` | admin URL path without leading slash (default `control`) |
| `ACES_WORKBENCH_SECURE_COOKIES` | secure session/CSRF cookies (default on when debug is off) |
| `ACES_WORKBENCH_SSL_REDIRECT` | redirect HTTP→HTTPS at the app (default on when debug is off) |
| `ACES_WORKBENCH_HSTS_SECONDS` | HSTS max-age (default `31536000` when debug is off) |
Expand All @@ -66,7 +67,9 @@ The HTTPS controls are on by default outside debug mode; the app trusts the
`X-Forwarded-Proto` header so the redirect does not loop behind a TLS-terminating
proxy. A Content-Security-Policy with a per-request script nonce (no inline
scripts) is applied to every response except the Django admin, which ships inline
scripts it does not nonce — keep `/admin/` restricted to administrators. The
scripts it does not nonce — keep the configured admin path restricted to
administrators. The default path is `/control/`; set
`ACES_WORKBENCH_ADMIN_PATH` for a deployment-specific path. The
in-memory rate-limit cache is per worker; set `ACES_WORKBENCH_CACHE_URL` to a
shared cache so limits hold across processes.

Expand Down
2 changes: 1 addition & 1 deletion docs/go-live-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Work through this before exposing an instance to the internet. See
understand why a warning is acceptable for your deployment.
- [ ] Migrations are applied (`aces-workbench migrate`).
- [ ] The first administrator exists (`aces-workbench createadmin`).
- [ ] The Django admin (`/admin/`) is restricted to administrators.
- [ ] The configured Django admin path is restricted to administrators.
- [ ] Sign in over HTTPS, create a project, and send yourself an invitation to
confirm email delivery end to end.

Expand Down
2 changes: 1 addition & 1 deletion docs/prerequisites.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Everything above, plus — decide and provision these up front:
- **A shared cache** (for example Redis, via `ACES_WORKBENCH_CACHE_URL`) if you
run more than one worker process, so login and request rate limits are counted
across all workers.
- Somewhere to keep the Django admin (`/admin/`) restricted to administrators.
- Somewhere to keep the configured Django admin path restricted to administrators.

!!! tip
Set these as environment variables before the first public start, then run
Expand Down
3 changes: 2 additions & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ used and sessions reset on every restart.

## 3. Create projects and add people

Sign in at `/accounts/login/` and open the Django admin at `/admin/`.
Sign in at `/accounts/login/` and open the Django admin at `/control/` unless
you changed `ACES_WORKBENCH_ADMIN_PATH`.

- **Create a project**: Admin → Projects → Add.
- **Add a member you can manage directly**: Admin → Users → Add (this sets a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,7 @@ <h2>Delete your account</h2>
This permanently deletes your account and the comments and decisions you
authored. This cannot be undone.
</p>
<form method="post" action="{% url 'account-delete' %}">
{% csrf_token %}
<button type="submit">Delete my account</button>
</form>
<p><a class="button-danger" href="{% url 'account-delete' %}">Review deletion steps</a></p>
</section>

<p><a href="{% url 'privacy' %}">Privacy notice</a></p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% extends "workbench/base.html" %}

{% block title %}Delete account — ACES Scenario Workbench{% endblock %}

{% block content %}
<p class="breadcrumb"><a href="{% url 'account' %}">Account</a> / Delete account</p>
<section class="panel readable danger-zone">
<h1>Delete your account</h1>
<p>
This permanently removes your account and the comments and decisions you
authored. Activity records remain for audit history with your identity
removed. This action cannot be undone.
</p>
{% if messages %}
<div class="error-summary" role="alert">
{% for message in messages %}<p>{{ message }}</p>{% endfor %}
</div>
{% endif %}
<form method="post" action="{% url 'account-delete' %}" class="stacked-form">
{% csrf_token %}
<div class="field">
<label for="confirm-email">Type your email address to confirm</label>
<input id="confirm-email" name="confirm_email" type="email" autocomplete="email" required>
</div>
<div class="button-row">
<button type="submit" class="button-danger">Delete my account</button>
<a class="button-secondary" href="{% url 'account' %}">Cancel</a>
</div>
</form>
</section>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,39 @@
{% block title %}Sign in — ACES Scenario Workbench{% endblock %}

{% block content %}
<h1>Sign in</h1>
{% if form.errors %}
<p role="alert">Your email and password did not match. Please try again.</p>
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{{ form.as_p }}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Sign in</button>
</form>
<p><a href="{% url 'password_reset' %}">Forgot your password?</a></p>
<section class="auth-shell">
<div class="panel auth-card">
<p class="eyebrow">Project workspace</p>
<h1>Sign in</h1>
<p class="subhead">Use the account your workspace administrator created or invited.</p>
{% if form.errors %}
<div class="error-summary" role="alert">
<p>Your email and password did not match. Please try again.</p>
</div>
{% endif %}
<form method="post" action="{% url 'login' %}" class="stacked-form">
{% csrf_token %}
<div class="field">
<label for="{{ form.username.id_for_label }}">Email</label>
{{ form.username }}
{% if form.username.errors %}<div class="field-error">{{ form.username.errors }}</div>{% endif %}
</div>
<div class="field">
<label for="{{ form.password.id_for_label }}">Password</label>
{{ form.password }}
{% if form.password.errors %}<div class="field-error">{{ form.password.errors }}</div>{% endif %}
</div>
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Sign in</button>
</form>
<p><a href="{% url 'password_reset' %}">Forgot your password?</a></p>
</div>
<aside class="auth-note">
<h2>No open registration</h2>
<p>
Access is invite-only. Ask a workspace administrator for an invitation
if you need access to a project.
</p>
</aside>
</section>
{% endblock %}
11 changes: 8 additions & 3 deletions src/aces_scenario_workbench/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from django.views.decorators.http import require_GET, require_http_methods
from django_ratelimit.decorators import ratelimit

from .forms import AcceptInvitationForm
Expand Down Expand Up @@ -113,9 +113,14 @@ def account_export(request: HttpRequest) -> JsonResponse:


@login_required
@require_POST
@require_http_methods(["GET", "POST"])
def account_delete(request: HttpRequest) -> HttpResponse:
"""Delete the signed-in user's account and personal content (GDPR erasure)."""
"""Confirm and delete the signed-in user's account and personal content."""
if request.method == "GET":
return render(request, "accounts/account_confirm_delete.html")
if request.POST.get("confirm_email", "").strip().lower() != request.user.email.lower():
messages.error(request, "Enter your email address to confirm account deletion.")
return render(request, "accounts/account_confirm_delete.html", status=400)
user = request.user
logout(request)
user.delete()
Expand Down
Loading
Loading