Allowing unauthenticated users to see scopes

This commit is contained in:
Ian Roddis committed 2026-08-18 13:44:55 -03:00
1 parent 2ce4f91e4f
commit 3253d8e858
8 files changed
+136 -27

No files matched your search

+1 -1
View File
@@ -49,7 +49,7 @@ def create_app() -> FastAPI:
# Preserve the originally requested URL so we can return there after
# login. Only meaningful for GET navigations (the ones users deep-link).
login_url = "/login"
if request.method == "GET" and request.url.path not in ("/", "/login"):
if request.method == "GET" and request.url.path != "/login":
target = request.url.path
if request.url.query:
target += "?" + request.url.query
+7
View File
@@ -29,6 +29,7 @@ async def build(
scope_model_id: int | None = None,
sub_part_type_id: int | None = None,
fits_part_type_id: int | None = None,
only_exact: bool = False,
) -> dict:
"""Return the context for ``scopes.html``.
@@ -40,6 +41,10 @@ async def build(
themselves — this always returns the full filtered set, since
``fits_part_type_id`` is a post-filter that can't be pushed into the DB query.
``only_exact`` drops ``possible`` fits entirely, keeping only user-confirmed
``exact`` ones — used for anonymous visitors, who shouldn't see unconfirmed
fit candidates.
Shape::
{
@@ -80,6 +85,8 @@ async def build(
fits = await fit_crud.list_for_parts(
db, list(scope_ids), exclude_label=FitLabel.NONE
)
if only_exact:
fits = [f for f in fits if f.label == FitLabel.EXACT]
# Resolve the "other" (non-scope) part on each fit in a single batched query.
other_ids: set[int] = set()
+8 -3
View File
@@ -1,11 +1,12 @@
{% set su = user.is_superuser %}
{% set p = user.permissions %}
{% set su = user.is_superuser if user else False %}
{% set p = user.permissions if user else [] %}
{% set can_admin = su or "users:read" in p or "roles:read" in p or "audit_log:read" in p %}
{% set can_connections = su or "clamp_connections:read" in p or "iso_threads:read" in p or "static_connections:read" in p or "unc_threads:read" in p %}
{% set can_labels = su or "attachment_point_names:read" in p or "manufacturers:read" in p or "part_colours:read" in p or "part_types:read" in p or "scope_models:read" in p or "sub_part_types:read" in p %}
{% set can_database = su or "imported_parts:read" in p or "augmented_parts:read" in p or "attachment_points:read" in p %}
{% set can_parts = can_connections or can_labels or can_database %}
{% set can_scopes = su or ("augmented_parts:read" in p and "fits:read" in p) %}
{# /scopes is public, so anonymous visitors see the link too; logged-in users still need the read perms. #}
{% set can_scopes = (not user) or su or ("augmented_parts:read" in p and "fits:read" in p) %}
{# ── SVG icon sprite ───────────────────────────────────────────────────────── #}
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
@@ -232,8 +233,12 @@
<a href="/docs" target="_blank">{{ icon("book-open") }} API docs</a>
</nav>
{% if user %}
<form method="post" action="/logout" class="logout">
<span class="muted">{{ user.email }}</span>
<button type="submit" class="btn btn-ghost">Sign out</button>
</form>
{% else %}
<a href="/login" class="btn btn-ghost">Sign in</a>
{% endif %}
</header>
+2 -1
View File
@@ -7,7 +7,8 @@
<p class="muted">
Every scope on record, with the accessories that fit it. Each part-type
column lists matching parts; <span class="fit-badge fit-badge-exact">Exact</span>
is a confirmed fit, <span class="fit-badge fit-badge-possible">Possible</span> a candidate.
is a confirmed fit{% if not only_exact %}, <span class="fit-badge fit-badge-possible">Possible</span> a candidate{% endif %}.
{% if only_exact %}<a href="/login">Sign in</a> to also see unconfirmed candidate fits.{% endif %}
</p>
<div class="filter-bar">
+6 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.deps import get_optional_web_user
from app.models.user import User
from app.services import ldap as ldap_service
from app.services.auth import issue_token_pair
@@ -46,8 +46,12 @@ def _set_auth_cookie(response: Response, access_token: str) -> None:
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
user: User = Depends(get_current_web_user),
user: User | None = Depends(get_optional_web_user),
):
# Anonymous visitors land on the public Scopes page instead of being
# bounced to login; the dashboard itself requires an account.
if user is None:
return RedirectResponse(url="/scopes", status_code=status.HTTP_302_FOUND)
return templates.TemplateResponse(request, "dashboard.html", {"user": user})
+45 -10
View File
@@ -5,7 +5,7 @@ non-Scope part type listing the accessories that fit each scope. Supports HTMX
search/filter over scope attributes and required accessory fits.
"""
from fastapi import APIRouter, Depends, Form, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +16,7 @@ from app.crud import manufacturer as manufacturer_crud
from app.crud import part_type as part_type_crud
from app.crud import scope_model as scope_model_crud
from app.crud import sub_part_type as sub_part_type_crud
from app.deps import get_optional_web_user
from app.models.user import User
from app.services import scope_guide as scope_guide_service
from app.web.admin import web_require
@@ -27,17 +28,39 @@ router = APIRouter(include_in_schema=False)
_PAGE_SIZE = 50
_TABLE_TARGET = "#scopes-table-card"
_FILTER_PATH = "/scopes/filter"
_REQUIRED_PERMS = {"augmented_parts:read", "fits:read"}
def _to_int(value: str) -> int | None:
return int(value) if value.strip() else None
def _can_write_scopes(user: User) -> bool:
return user.is_superuser or "augmented_parts:write" in user.permissions
def _can_write_scopes(user: User | None) -> bool:
return user is not None and (
user.is_superuser or "augmented_parts:write" in user.permissions
)
async def _build(db: AsyncSession, filters: dict) -> dict:
async def _optional_scopes_user(
user: User | None = Depends(get_optional_web_user),
) -> User | None:
"""Anyone may view the scopes page; logged-in users still need the read perms.
Anonymous visitors are allowed through (and get an exact-fits-only view —
see ``only_exact`` in ``scope_guide.build``); a logged-in user missing the
permissions gets the usual 403, matching ``web_require``'s behaviour.
"""
if user is not None and not (
user.is_superuser or _REQUIRED_PERMS.issubset(user.permissions)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this page.",
)
return user
async def _build(db: AsyncSession, filters: dict, only_exact: bool = False) -> dict:
"""Run the guide query for the given raw (string) filter values.
Always returns the full matching row set — pagination is applied by the
@@ -50,17 +73,22 @@ async def _build(db: AsyncSession, filters: dict) -> dict:
scope_model_id=_to_int(filters["scope_model_id"]),
sub_part_type_id=_to_int(filters["sub_part_type_id"]),
fits_part_type_id=_to_int(filters["fits_part_type_id"]),
only_exact=only_exact,
)
async def _paginated_guide(
db: AsyncSession, filters: dict, page: int, page_size: int
db: AsyncSession,
filters: dict,
page: int,
page_size: int,
only_exact: bool = False,
) -> tuple[dict, dict]:
"""Run the guide query, then slice its rows to the requested page.
Returns ``(guide, pagination)`` where ``guide["rows"]`` is already sliced.
"""
guide = await _build(db, filters)
guide = await _build(db, filters, only_exact=only_exact)
all_rows = guide["rows"]
pagination = paginate(
_FILTER_PATH, filters, page, page_size, len(all_rows), _TABLE_TARGET
@@ -95,7 +123,7 @@ async def scopes_page(
sub_part_type_id: str = "",
fits_part_type_id: str = "",
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read", "fits:read")),
user: User | None = Depends(_optional_scopes_user),
):
filters = {
"description": description,
@@ -104,7 +132,10 @@ async def scopes_page(
"sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id,
}
guide, pagination = await _paginated_guide(db, filters, 1, _PAGE_SIZE)
only_exact = user is None
guide, pagination = await _paginated_guide(
db, filters, 1, _PAGE_SIZE, only_exact=only_exact
)
return templates.TemplateResponse(
request,
"scopes.html",
@@ -112,6 +143,7 @@ async def scopes_page(
"user": user,
"filters": filters,
"can_write_scopes": _can_write_scopes(user),
"only_exact": only_exact,
"pagination": pagination,
**guide,
**(await _filter_options(db)),
@@ -130,7 +162,7 @@ async def scopes_filter(
page: int = 1,
page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read", "fits:read")),
user: User | None = Depends(_optional_scopes_user),
):
filters = {
"description": description,
@@ -139,7 +171,10 @@ async def scopes_filter(
"sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id,
}
guide, pagination = await _paginated_guide(db, filters, page, page_size)
only_exact = user is None
guide, pagination = await _paginated_guide(
db, filters, page, page_size, only_exact=only_exact
)
return templates.TemplateResponse(
request,
"_scopes_table_card.html",