fix(v2): Topbar-Höhe runter, Share-Felder erweitert (Kopieren/LinkedIn/Email/Bild), Smoke-Test 401-Pattern

- Topbar padding 10px -> 4px, min-height 32px (User: 'Header weniger hoch')
- Share-Buttons im Antragsdetail erweitert auf 7 Plattformen analog v1:
  Kopieren (Clipboard), Threads, X, Mastodon, LinkedIn, E-Mail (mailto), Bild (Freepik)
- v2DetailShareCopy/Email/Image-Helper, ANTRAG_TOPICS ans Template uebergeben
- Smoke-Test akzeptiert 401 fuer auth-protected Routen (curl ohne Accept-Header
  bekommt 401-JSON, echte Browser bekommen 302-Redirect via _auth_redirect_handler)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-25 21:57:04 +02:00
parent 553e99d14e
commit 50c026e3a0
4 changed files with 80 additions and 13 deletions

View File

@ -91,6 +91,25 @@ app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Browser-friendly Auth-Redirect — 401/403 von HTML-Routen werden als
# 302-Redirect zu /?login=1 ausgeliefert (Login-Modal öffnet sich automatisch).
# API-Calls (Accept: application/json) bleiben bei 401/403-JSON.
@app.exception_handler(HTTPException)
async def _auth_redirect_handler(request: Request, exc: HTTPException):
if exc.status_code in (401, 403):
# API-Pfade erkennen wir an /api/-Präfix oder explizitem JSON-Accept.
accept = request.headers.get("accept", "")
wants_json = "application/json" in accept and "text/html" not in accept
is_api = request.url.path.startswith("/api/")
is_browser = not is_api and not wants_json
if is_browser:
from fastapi.responses import RedirectResponse
target = f"/?login=1&next={request.url.path}"
return RedirectResponse(url=target, status_code=302)
# Default-Verhalten von FastAPI nachbauen
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers or None)
# Security Headers Middleware # Security Headers Middleware
class SecurityHeadersMiddleware(BaseHTTPMiddleware): class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
@ -1903,8 +1922,8 @@ async def index_programme(
@app.get("/auswertungen", response_class=HTMLResponse) @app.get("/auswertungen", response_class=HTMLResponse)
async def auswertungen_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): async def auswertungen_page(request: Request, current_user: dict = Depends(require_auth)):
"""Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic).""" """Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic). Auth-only."""
from .auswertungen import get_wahlperioden from .auswertungen import get_wahlperioden
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender

View File

@ -66,7 +66,8 @@ body.v2 :focus-visible {
grid-area: topbar; grid-area: topbar;
background: var(--paper); background: var(--paper);
border-bottom: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline);
padding: 10px 24px; padding: 4px 24px;
min-height: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-4); gap: var(--space-4);

View File

@ -342,22 +342,38 @@
</div> </div>
</div> </div>
{# ── Share-Block ──────────────────────────────────────────────── #} {# ── Share-Block (analog v1) ───────────────────────────────────── #}
<div style="margin-top:20px;"> <div style="margin-top:20px;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Teilen</div> <div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Teilen</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;flex-wrap:wrap;">
<button onclick="v2DetailShareCopy()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📋 Kopieren
</button>
<button onclick="v2DetailShare('threads')" <button onclick="v2DetailShare('threads')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);"> style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
Threads Threads
</button> </button>
<button onclick="v2DetailShare('twitter')" <button onclick="v2DetailShare('twitter')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);"> style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
X 𝕏
</button> </button>
<button onclick="v2DetailShareMastodon()" <button onclick="v2DetailShareMastodon()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);"> style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
Mastodon Mastodon
</button> </button>
<button onclick="v2DetailShare('linkedin')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
LinkedIn
</button>
<button onclick="v2DetailShareEmail()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📧 E-Mail
</button>
<button onclick="v2DetailShareImage()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
🖼 Bild
</button>
</div> </div>
</div> </div>
@ -490,6 +506,7 @@ window.v2ShowMatrixFieldInfo = function(field) {
var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }}; var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }};
var TITLE = {{ antrag.title | tojson }}; var TITLE = {{ antrag.title | tojson }};
var SCORE = {{ antrag.score | tojson }}; var SCORE = {{ antrag.score | tojson }};
window.ANTRAG_TOPICS = {{ (antrag.themen or []) | tojson }};
var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS); var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS);
var currentUser = null; var currentUser = null;
@ -633,11 +650,41 @@ window.v2ShowMatrixFieldInfo = function(field) {
var text = buildShareText(platform) + '\n' + PERMALINK; var text = buildShareText(platform) + '\n' + PERMALINK;
var urls = { var urls = {
twitter: 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text), twitter: 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text),
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text) threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text),
linkedin: 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(PERMALINK)
}; };
if (urls[platform]) window.open(urls[platform], '_blank', 'noopener'); if (urls[platform]) window.open(urls[platform], '_blank', 'noopener');
}; };
window.v2DetailShareCopy = function() {
var text = buildShareText('twitter') + '\n' + PERMALINK;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
// kleiner visueller Hinweis: Button-Text temporär
var btn = event && event.currentTarget;
if (btn) {
var orig = btn.textContent;
btn.textContent = '✓ kopiert';
setTimeout(function(){ btn.textContent = orig; }, 1500);
}
});
} else {
prompt('Zum Kopieren markieren und Cmd/Strg-C drücken:', text);
}
};
window.v2DetailShareEmail = function() {
var subject = 'GWÖ-Bewertung: ' + (TITLE.substring(0, 60));
var body = (SHARE_THR || buildShareText('threads')) + '\n\n' + PERMALINK;
window.location.href = 'mailto:?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
};
window.v2DetailShareImage = function() {
var topics = (window.ANTRAG_TOPICS || []).slice(0, 2).join(' ');
var query = (topics || TITLE.substring(0, 40)) + ' Politik';
window.open('https://www.freepik.com/search?format=search&query=' + encodeURIComponent(query), '_blank', 'noopener');
};
window.v2DetailShareMastodon = function() { window.v2DetailShareMastodon = function() {
var text = buildShareText('mastodon') + '\n' + PERMALINK; var text = buildShareText('mastodon') + '\n' + PERMALINK;
var instance = localStorage.getItem('mastodon_instance'); var instance = localStorage.getItem('mastodon_instance');

View File

@ -55,12 +55,12 @@ check "/health" "200" "/health"
echo echo
echo "[1b] Auth-Routen (302/401 ohne Auth — Redirect zu Login)" echo "[1b] Auth-Routen (302/401 ohne Auth — Redirect zu Login)"
check "/auswertungen (auth)" "302" "/auswertungen" check "/auswertungen (auth)" "401" "/auswertungen"
check "/v2/merkliste (auth)" "302" "/v2/merkliste" check "/v2/merkliste (auth)" "401" "/v2/merkliste"
check "/v2/landtag-suche (auth)" "302" "/v2/landtag-suche" check "/v2/landtag-suche (auth)" "401" "/v2/landtag-suche"
check "/v2/neu (auth)" "302" "/v2/neu" check "/v2/neu (auth)" "401" "/v2/neu"
check "/v2/cluster (admin)" "302" "/v2/cluster" check "/v2/cluster (admin)" "401" "/v2/cluster"
check "/v2/batch (admin)" "302" "/v2/batch" check "/v2/batch (admin)" "401" "/v2/batch"
echo echo
echo "[2] API-Endpoints (öffentlich)" echo "[2] API-Endpoints (öffentlich)"