From 422cd613f6ee6f21ceadcb4634d0cbe292ce630c Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 11 Aug 2026 02:31:55 +0200 Subject: [PATCH] Add app home Spaces shell and session diagnostics profile menu Post-login lands on Spaces empty state instead of a principal dump. Profile menu exposes Session details with identity, tenant, roles/groups, and authz diagnostics for operator refinement (CSOC-WP-0004 T01/T07). --- .../apps/core/context_processors.py | 8 +- coulomb_social/apps/core/principal.py | 75 +++++++++++ coulomb_social/apps/core/urls.py | 1 + coulomb_social/apps/core/views.py | 66 +++++----- coulomb_social/apps/identity/services.py | 6 +- coulomb_social/templates/base.html | 116 +++++++++++++++--- .../templates/core/account_session.html | 52 ++++++++ coulomb_social/templates/core/app_home.html | 41 ++++--- tests/test_shell.py | 42 ++++++- ...SOC-WP-0004-app-shell-and-space-content.md | 85 ++++++++++--- 10 files changed, 404 insertions(+), 88 deletions(-) create mode 100644 coulomb_social/apps/core/principal.py create mode 100644 coulomb_social/templates/core/account_session.html diff --git a/coulomb_social/apps/core/context_processors.py b/coulomb_social/apps/core/context_processors.py index 5821bad..fdd311d 100644 --- a/coulomb_social/apps/core/context_processors.py +++ b/coulomb_social/apps/core/context_processors.py @@ -1,10 +1,16 @@ from django.conf import settings +from coulomb_social.apps.core.principal import display_name_for + def site_context(request): - return { + ctx = { "site_name": "coulomb.social", "default_tenant_id": settings.DEFAULT_TENANT_ID, "oidc_enabled": settings.OIDC_ENABLED, "registration_enabled": bool(settings.NETKINGDOM_REGISTRATION_URL), + "nav_display_name": "", } + if getattr(request, "user", None) is not None and request.user.is_authenticated: + ctx["nav_display_name"] = display_name_for(request) + return ctx diff --git a/coulomb_social/apps/core/principal.py b/coulomb_social/apps/core/principal.py new file mode 100644 index 0000000..630c685 --- /dev/null +++ b/coulomb_social/apps/core/principal.py @@ -0,0 +1,75 @@ +"""Build principal/session diagnostic dicts for templates (no secrets).""" + +from __future__ import annotations + +from typing import Any + +from django.http import HttpRequest + +from coulomb_social.apps.identity import flex_auth + + +def display_name_for(request: HttpRequest) -> str: + member = getattr(request.user, "member", None) + if member and member.display_name: + return member.display_name + return request.user.get_full_name() or request.user.get_username() + + +def build_principal(request: HttpRequest, *, authz_resource_id: str = "app_home") -> dict[str, Any]: + """Principal card fields + optional session claim diagnostics.""" + member = getattr(request.user, "member", None) + subject = member.subject if member else request.user.get_username() + tenant = member.tenant_id if member else "" + decision = flex_auth.check( + "shell:view", + resource="shell", + resource_id=authz_resource_id, + subject=subject, + tenant=tenant, + attributes={ + "issuer": member.issuer if member else "", + "user_engine_user_id": str(member.user_engine_user_id) if member else "", + }, + ) + groups = request.session.get("identity_groups") or [] + roles = request.session.get("identity_roles") or [] + assurance = request.session.get("identity_assurance") or {} + return { + "username": request.user.get_username(), + "display_name": display_name_for(request), + "email": (member.email if member else "") or request.user.email or "", + "issuer": member.issuer if member else "", + "subject": member.subject if member else "", + "user_engine_user_id": str(member.user_engine_user_id) if member else "", + "tenant_id": member.tenant_id if member else "", + "user_engine_source": request.session.get("user_engine_source", ""), + "principal_type": request.session.get("identity_principal_type", "") or "—", + "groups": groups, + "roles": roles, + "groups_display": ", ".join(str(g) for g in groups) if groups else "—", + "roles_display": ", ".join(str(r) for r in roles) if roles else "—", + "assurance": assurance, + "assurance_display": _format_assurance(assurance), + "authz_decision_id": decision.decision_id, + "authz_reason": decision.reason, + "authz_allow": decision.allow, + "member": member, + "decision": decision, + } + + +def _format_assurance(assurance: dict[str, Any]) -> str: + if not assurance: + return "—" + aal = assurance.get("aal") or assurance.get("acr") or "" + methods = assurance.get("methods") or [] + mfa = assurance.get("mfa") + parts: list[str] = [] + if aal: + parts.append(str(aal)) + if methods: + parts.append("methods=" + ",".join(str(m) for m in methods)) + if mfa is not None: + parts.append(f"mfa={mfa}") + return "; ".join(parts) if parts else str(assurance) diff --git a/coulomb_social/apps/core/urls.py b/coulomb_social/apps/core/urls.py index 9a07cae..4f0cc6e 100644 --- a/coulomb_social/apps/core/urls.py +++ b/coulomb_social/apps/core/urls.py @@ -7,4 +7,5 @@ app_name = "core" urlpatterns = [ path("", views.landing, name="landing"), path("app/", views.app_home, name="app_home"), + path("account/session/", views.account_session, name="account_session"), ] diff --git a/coulomb_social/apps/core/views.py b/coulomb_social/apps/core/views.py index 49ee420..0cab190 100644 --- a/coulomb_social/apps/core/views.py +++ b/coulomb_social/apps/core/views.py @@ -1,61 +1,51 @@ from django.contrib.auth.decorators import login_required from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, JsonResponse -from django.shortcuts import render +from django.shortcuts import redirect, render -from coulomb_social.apps.identity import flex_auth +from .principal import build_principal def landing(request: HttpRequest) -> HttpResponse: if request.user.is_authenticated: - from django.shortcuts import redirect - return redirect("core:app_home") return render(request, "core/landing.html") @login_required def app_home(request: HttpRequest) -> HttpResponse: - member = getattr(request.user, "member", None) - subject = member.subject if member else request.user.get_username() - tenant = member.tenant_id if member else "" - decision = flex_auth.check( - "shell:view", - resource="shell", - resource_id="app_home", - subject=subject, - tenant=tenant, - attributes={ - "issuer": member.issuer if member else "", - "user_engine_user_id": str(member.user_engine_user_id) if member else "", - }, - ) - if not decision.allow: + principal = build_principal(request, authz_resource_id="app_home") + if not principal["authz_allow"]: return HttpResponseForbidden( - f"Not authorized to view shell ({decision.reason})." + f"Not authorized to view shell ({principal['authz_reason']})." ) + # Spaces list is empty until CSOC-WP-0004-T02; keep the product empty-state. + spaces: list[dict] = [] return render( request, "core/app_home.html", { - "member": member, - "principal": { - "username": request.user.get_username(), - "display_name": ( - member.display_name - if member and member.display_name - else request.user.get_full_name() or request.user.get_username() - ), - "issuer": member.issuer if member else "", - "subject": member.subject if member else "", - "user_engine_user_id": ( - str(member.user_engine_user_id) if member else "" - ), - "tenant_id": member.tenant_id if member else "", - "user_engine_source": request.session.get("user_engine_source", ""), - "authz_decision_id": decision.decision_id, - "authz_reason": decision.reason, - }, + "principal": principal, + "display_name": principal["display_name"], + "spaces": spaces, + }, + ) + + +@login_required +def account_session(request: HttpRequest) -> HttpResponse: + """Principal / session diagnostics (profile menu → T07).""" + principal = build_principal(request, authz_resource_id="account_session") + if not principal["authz_allow"]: + return HttpResponseForbidden( + f"Not authorized to view session details ({principal['authz_reason']})." + ) + return render( + request, + "core/account_session.html", + { + "principal": principal, + "display_name": principal["display_name"], }, ) diff --git a/coulomb_social/apps/identity/services.py b/coulomb_social/apps/identity/services.py index 76c81d0..d399251 100644 --- a/coulomb_social/apps/identity/services.py +++ b/coulomb_social/apps/identity/services.py @@ -59,9 +59,13 @@ def establish_session(request: HttpRequest, claims: IdentityClaims) -> Member: member.last_login_at = timezone.now() member.save() - # Stash provenance for debugging shell (no secrets) + # Stash non-secret claim provenance for account diagnostics (T07) request.session["user_engine_source"] = link.source request.session["user_engine_user_id"] = link.user_id + request.session["identity_groups"] = list(claims.groups) + request.session["identity_roles"] = list(claims.roles) + request.session["identity_principal_type"] = claims.principal_type or "human" + request.session["identity_assurance"] = dict(claims.assurance or {}) login(request, user, backend="django.contrib.auth.backends.ModelBackend") return member diff --git a/coulomb_social/templates/base.html b/coulomb_social/templates/base.html index 440d77d..3794a7f 100644 --- a/coulomb_social/templates/base.html +++ b/coulomb_social/templates/base.html @@ -20,15 +20,36 @@ color: var(--color-text); line-height: 1.5; } - header { + header.app-header { display: flex; justify-content: space-between; align-items: center; - padding: 1rem 1.5rem; + gap: 1rem; + padding: 0.85rem 1.5rem; border-bottom: 1px solid #e5e5e5; } - header a { color: inherit; text-decoration: none; font-weight: 600; } - main { max-width: 48rem; margin: 0 auto; padding: 2.5rem 1.5rem; } + header.app-header a.brand { + color: inherit; + text-decoration: none; + font-weight: 600; + white-space: nowrap; + } + header .nav-main { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + margin-left: 1.5rem; + } + header .nav-main a { + color: var(--color-muted); + text-decoration: none; + font-weight: 500; + font-size: 0.95rem; + } + header .nav-main a:hover, + header .nav-main a.active { color: var(--color-text); } + header .nav-end { display: flex; align-items: center; gap: 0.75rem; } .btn { display: inline-block; padding: 0.75rem 1.25rem; @@ -42,6 +63,7 @@ cursor: pointer; } .btn.secondary { background: #171717; } + .btn.small { padding: 0.4rem 0.85rem; font-size: 0.9rem; } .muted { color: var(--color-muted); } .card { border: 1px solid #e5e5e5; @@ -59,22 +81,88 @@ border: 1px solid #ccc; border-radius: 6px; } - dl { display: grid; grid-template-columns: 10rem 1fr; gap: 0.35rem 1rem; } + dl { display: grid; grid-template-columns: 11rem 1fr; gap: 0.35rem 1rem; } dt { color: var(--color-muted); } dd { margin: 0; word-break: break-all; } + main { max-width: 48rem; margin: 0 auto; padding: 2.5rem 1.5rem; } + + /* Profile menu */ + .profile-menu { position: relative; } + .profile-menu > summary { + list-style: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + border: 1px solid #e5e5e5; + border-radius: 999px; + font-weight: 600; + font-size: 0.9rem; + user-select: none; + } + .profile-menu > summary::-webkit-details-marker { display: none; } + .profile-menu > summary:hover { border-color: #ccc; } + .profile-menu[open] > summary { border-color: var(--color-primary); } + .profile-menu .menu-panel { + position: absolute; + right: 0; + top: calc(100% + 0.35rem); + min-width: 14rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0,0,0,0.08); + padding: 0.4rem 0; + z-index: 40; + } + .profile-menu .menu-panel a, + .profile-menu .menu-panel button { + display: block; + width: 100%; + text-align: left; + padding: 0.55rem 1rem; + border: none; + background: none; + font: inherit; + color: inherit; + text-decoration: none; + cursor: pointer; + } + .profile-menu .menu-panel a:hover, + .profile-menu .menu-panel button:hover { background: #f5f5f5; } + .profile-menu .menu-meta { + padding: 0.5rem 1rem 0.35rem; + font-size: 0.8rem; + color: var(--color-muted); + border-bottom: 1px solid #f0f0f0; + margin-bottom: 0.25rem; + } {% block extra_head %}{% endblock %} -
- {{ site_name }} - +
+ {{ site_name }} + {% if user.is_authenticated %} + + + {% else %} + + {% endif %}
{% if messages %} diff --git a/coulomb_social/templates/core/account_session.html b/coulomb_social/templates/core/account_session.html new file mode 100644 index 0000000..2b5f83e --- /dev/null +++ b/coulomb_social/templates/core/account_session.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Session details — {{ site_name }}{% endblock %} +{% block content %} +

+ ← Spaces +

+

Session details

+

+ Identity and authorization diagnostics for refining user, group, role, + and tenant management. No secrets are shown. +

+ +
+

Identity

+
+
Display name
{{ principal.display_name }}
+
Username
{{ principal.username }}
+
Email
{{ principal.email|default:"—" }}
+
Issuer
{{ principal.issuer|default:"—" }}
+
Subject
{{ principal.subject|default:"—" }}
+
Principal type
{{ principal.principal_type }}
+
+
+ +
+

Platform user

+
+
user-engine id
{{ principal.user_engine_user_id|default:"—" }}
+
user-engine source
{{ principal.user_engine_source|default:"—" }}
+
+
+ +
+

Tenancy, roles & groups

+
+
Tenant
{{ principal.tenant_id|default:"—" }}
+
Roles
{{ principal.roles_display }}
+
Groups
{{ principal.groups_display }}
+
Assurance
{{ principal.assurance_display }}
+
+
+ +
+

Authorization

+
+
Shell check
+
{% if principal.authz_allow %}allow{% else %}deny{% endif %}
+
Reason
{{ principal.authz_reason }}
+
Decision id
{{ principal.authz_decision_id }}
+
+
+{% endblock %} diff --git a/coulomb_social/templates/core/app_home.html b/coulomb_social/templates/core/app_home.html index cd8985e..6a1a660 100644 --- a/coulomb_social/templates/core/app_home.html +++ b/coulomb_social/templates/core/app_home.html @@ -1,19 +1,28 @@ {% extends "base.html" %} -{% block title %}Home — {{ site_name }}{% endblock %} +{% block title %}Spaces — {{ site_name }}{% endblock %} {% block content %} -

Signed in

-

Authenticated shell (CSOC-WP-0002). Content surfaces come later.

-
-

Principal

-
-
Display name
{{ principal.display_name }}
-
Username
{{ principal.username }}
-
Tenant
{{ principal.tenant_id }}
-
Issuer
{{ principal.issuer }}
-
Subject
{{ principal.subject }}
-
user-engine id
{{ principal.user_engine_user_id }}
-
user-engine source
{{ principal.user_engine_source|default:"—" }}
-
authz
{{ principal.authz_reason }} ({{ principal.authz_decision_id }})
-
-
+

Spaces

+

+ Co-creation spaces for your tenant. + Content will live as markdown in Forgejo-backed repositories. +

+ + {% if spaces %} +
    + {% for space in spaces %} +
  • + {{ space.title }} + · {{ space.slug }} +
  • + {% endfor %} +
+ {% else %} +
+

No spaces yet

+

+ Spaces and Forgejo-backed content land in the next steps + (CSOC-WP-0004-T02+). Use the profile menu for session diagnostics. +

+
+ {% endif %} {% endblock %} diff --git a/tests/test_shell.py b/tests/test_shell.py index 6dd05d0..e268817 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -38,7 +38,7 @@ def test_app_home_requires_login(client): @pytest.mark.django_db -def test_dev_login_establishes_member_and_shell(client, settings): +def test_dev_login_lands_on_app_home_not_principal_dump(client, settings): settings.DEBUG = True settings.OIDC_ENABLED = False @@ -63,8 +63,45 @@ def test_dev_login_establishes_member_and_shell(client, settings): home = client.get(reverse("core:app_home")) assert home.status_code == 200 + assert b"Spaces" in home.content + assert b"No spaces yet" in home.content + # Principal dump moved off the home body + assert b"user-engine id" not in home.content + assert b"sub-abc" not in home.content + # Profile chrome shows display name assert b"Ada Lovelace" in home.content - assert b"sub-abc" in home.content + assert b"Session details" in home.content + + +@pytest.mark.django_db +def test_account_session_shows_principal_diagnostics(client, settings): + settings.DEBUG = True + settings.OIDC_ENABLED = False + client.post( + reverse("identity:dev_login"), + { + "subject": "sub-diag", + "issuer": "https://local.dev/issuer", + "name": "Diag User", + "email": "diag@example.com", + }, + ) + r = client.get(reverse("core:account_session")) + assert r.status_code == 200 + assert b"Session details" in r.content + assert b"sub-diag" in r.content + assert b"Diag User" in r.content + assert b"user-engine id" in r.content + assert b"Tenant" in r.content + assert b"Roles" in r.content + assert b"Groups" in r.content + + +@pytest.mark.django_db +def test_account_session_requires_login(client): + r = client.get(reverse("core:account_session")) + assert r.status_code == 302 + assert reverse("identity:login") in r["Location"] @pytest.mark.django_db @@ -75,7 +112,6 @@ def test_establish_session_idempotent(client, settings): email="x@example.com", name="Once", ) - # Use request factory via client session by posting twice settings.DEBUG = True settings.OIDC_ENABLED = False client.post( diff --git a/workplans/CSOC-WP-0004-app-shell-and-space-content.md b/workplans/CSOC-WP-0004-app-shell-and-space-content.md index 393329d..02cf671 100644 --- a/workplans/CSOC-WP-0004-app-shell-and-space-content.md +++ b/workplans/CSOC-WP-0004-app-shell-and-space-content.md @@ -9,6 +9,7 @@ owner: bernd topic_slug: coulomb-social created: "2026-08-10" updated: "2026-08-10" +# note: T07 added same day — principal diagnostics via profile menu depends_on: - CSOC-WP-0002 related: @@ -16,6 +17,7 @@ related: - CSOC-WP-0003 origin: operator origin_ref: session-2026-08-10-parallel-host-product-path +state_hub_workstream_id: "d10427aa-02d9-4ec9-a466-d7cdb1a9167e" --- # CSOC-WP-0004 — App shell entry and Forgejo-backed space content @@ -49,27 +51,33 @@ Migration of all Bubble spaces is **explicitly later** (`CSOC-WP-0001`). ```task id: CSOC-WP-0004-T01 -status: todo +status: done priority: high +state_hub_task_id: "7cce67c0-6234-4ef4-ad67-de952f5f4519" ``` Replace the dead-end “Signed in / Principal” page as the primary post-login destination with an **app home** that a member can use: - Clear primary navigation: Spaces (and placeholders for later surfaces). -- Principal summary available but secondary (account menu or `/account/`). +- Compact account control (display name / avatar) opening a **profile menu** + (full principal diagnostics live under **T07**, not on the home body). - Empty state when the member has no spaces yet (“Create space” or “No spaces”). - `LOGIN_REDIRECT_URL` and templates updated; design-extract tokens only as needed. **Done when:** after OIDC login on app.coulomb.social, tegwick lands on app home (not a debug-only principal card) and can navigate without guessing URLs. +2026-08-11: App home is Spaces empty state; header nav + profile menu; principal +dump removed from home body. Deploy with next image for app.coulomb.social. + ## T02 — Space domain model (metadata, tenant-keyed) ```task id: CSOC-WP-0004-T02 status: todo priority: high +state_hub_task_id: "5fd91718-151d-4216-acf2-2104a45cddf9" ``` Introduce `Space` (name pending Bubble vocabulary alignment) as application @@ -90,6 +98,7 @@ tenant isolation basics. id: CSOC-WP-0004-T03 status: todo priority: high +state_hub_task_id: "628243e0-732f-42c1-b4e3-9b8cd82ea530" ``` Write `docs/adr/ADR-0002-space-content-forgejo-markdown.md` deciding: @@ -110,6 +119,7 @@ and linked from INTENT/SCOPE. id: CSOC-WP-0004-T04 status: todo priority: high +state_hub_task_id: "77fa3454-6f2e-4a76-8a0c-ff7e13ca4b6a" ``` Implement a vertical slice: @@ -128,6 +138,7 @@ markdown sourced from Forgejo (not Bubble). id: CSOC-WP-0004-T05 status: todo priority: medium +state_hub_task_id: "30ed5def-2718-4379-834c-920e751e4d0b" ``` Minimal authoring or sync so content is not read-only forever: @@ -146,36 +157,80 @@ and (if in-app write exists) a save produces a commit without secrets in git. id: CSOC-WP-0004-T06 status: todo priority: medium +state_hub_task_id: "40a880d0-62b9-4ae4-ab3e-074e8901462a" ``` Document operator steps: create Forgejo org/repo, bind space, credentials env names, smoke checklist on app.coulomb.social. Update `docs/deploy.md` and `docs/identity/smoke.md` pointers as needed. +## T07 — Principal diagnostics via user profile menu + +```task +id: CSOC-WP-0004-T07 +status: done +priority: high +``` + +Keep the current principal card fields available as **detail information** +reachable from the **user profile menu** in the app chrome (not as the primary +post-login page body). Purpose: refine and diagnose identity wiring during +ongoing user, group, role, and tenant management work. + +Include at least the present shell fields (and extend as claims become available): + +| Area | Examples | +|------|----------| +| Identity | display name, username, issuer, subject | +| Platform user | user-engine id, user-engine source | +| Tenancy | tenant id / claims | +| Roles & groups | OIDC/groups/roles claims when present | +| Authz | flex-auth / shell decision reason + decision id | +| Session | assurance / AAL hints when present | + +UX: + +- Profile menu entry e.g. **Account** / **Session details** / **Identity** +- Detail view at a stable path (e.g. `/account/` or `/account/session/`) +- Readable for operators; no secrets (tokens, proxy secrets) ever rendered +- Sign out remains on the menu + +**Done when:** after T01 app chrome exists, tegwick can open the profile menu → +principal/session detail page and see the same diagnostic surface formerly on +the signed-in card, without that card being the home page. + +Ship with or immediately after **T01** (same PR is fine). + +2026-08-11: `/account/session/` holds principal diagnostics (identity, UE, +tenant, roles/groups, assurance, authz). Profile menu → **Session details**. +Session stashes groups/roles/assurance at login (no secrets). + --- ## Sequencing ```text -T01 app home entry - └─► T02 Space metadata - └─► T03 content ADR - └─► T04 read path (MVP value) - ├─► T05 write/sync - └─► T06 runbook +T01 app home entry ──┬─► T07 profile menu principal diagnostics + └─► T02 Space metadata + └─► T03 content ADR + └─► T04 read path (MVP value) + ├─► T05 write/sync + └─► T06 runbook ``` -T01 can ship alone to fix the “stuck on login confirmation” UX immediately. -T03 should land before large T04 investment if write-model choices are unclear; -a **provisional** ADR is enough to start T04 against a single seed repo. +T01 (+ T07) can ship alone to fix the “stuck on login confirmation” UX while +keeping identity diagnostics one click away. T03 should land before large T04 +investment if write-model choices are unclear; a **provisional** ADR is enough +to start T04 against a single seed repo. ## Acceptance (workplan) 1. Post-login journey is product-shaped (app home + spaces), not identity-debug-only. -2. Spaces exist as tenant-keyed app records. -3. Space page content is markdown backed by Forgejo with a working read path. -4. Bubble migration is still not required for demos on app.coulomb.social. -5. CSOC-WP-0001 can map Bubble pages onto the ADR layout when migration starts. +2. Principal/session diagnostics remain available from the user profile menu (T07). +3. Spaces exist as tenant-keyed app records. +4. Space page content is markdown backed by Forgejo with a working read path. +5. Bubble migration is still not required for demos on app.coulomb.social. +6. CSOC-WP-0001 can map Bubble pages onto the ADR layout when migration starts. ## Related