web admin mode

This commit is contained in:
2026-08-05 16:44:10 +02:00
parent c2d5ae45a2
commit 9458a88122
5 changed files with 234 additions and 7 deletions
+54 -4
View File
@@ -9,12 +9,14 @@ vorgeschalteten Reverse-Proxy mit Authelia, der den eingeloggten
Benutzernamen im Remote-User-Header mitschickt.""" Benutzernamen im Remote-User-Header mitschickt."""
import json import json
import logging import logging
import secrets
from datetime import date from datetime import date
from fastapi import Depends, FastAPI, Query, Request from fastapi import Depends, FastAPI, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
import db import db
from api.auth import get_current_user, resolve_account_for_user from api.auth import get_current_user, resolve_account_for_user
@@ -25,6 +27,7 @@ logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s]
logger = logging.getLogger("api") logger = logging.getLogger("api")
app = FastAPI(title="iCloud Contacts Sync Interne API", version="1.0.0") app = FastAPI(title="iCloud Contacts Sync Interne API", version="1.0.0")
app.add_middleware(SessionMiddleware, secret_key=secrets.token_hex(32), session_cookie="ics_session")
app.mount("/static", StaticFiles(directory="api/static"), name="static") app.mount("/static", StaticFiles(directory="api/static"), name="static")
templates = Jinja2Templates(directory="api/templates") templates = Jinja2Templates(directory="api/templates")
@@ -46,6 +49,16 @@ def _account_filter_clause(account_name: str | None) -> tuple[str, list]:
return "WHERE account = %s", [account_name] return "WHERE account = %s", [account_name]
def _resolve_effective_account(request: Request, current_user: str) -> tuple[str | None, bool, bool]:
account_name, is_admin = resolve_account_for_user(current_user)
if not is_admin:
return account_name, False, False
show_all = request.session.get("show_all", False)
if show_all:
return None, True, True
return account_name, True, False
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
@@ -178,7 +191,7 @@ def web_dashboard(
request: Request, request: Request,
current_user: str = Depends(get_current_user), current_user: str = Depends(get_current_user),
): ):
account_name, is_admin = resolve_account_for_user(current_user) account_name, is_admin, show_all = _resolve_effective_account(request, current_user)
with db.get_connection() as conn: with db.get_connection() as conn:
contact_count = db.get_contact_count(conn, account_name) contact_count = db.get_contact_count(conn, account_name)
@@ -222,6 +235,7 @@ def web_dashboard(
"request": request, "request": request,
"current_user": current_user, "current_user": current_user,
"is_admin": is_admin, "is_admin": is_admin,
"show_all": show_all,
"account_name": account_name or "alle Accounts", "account_name": account_name or "alle Accounts",
"contact_count": contact_count, "contact_count": contact_count,
"upcoming_birthdays": upcoming_birthdays, "upcoming_birthdays": upcoming_birthdays,
@@ -233,13 +247,47 @@ def web_dashboard(
) )
@app.get("/admin", response_class=HTMLResponse)
def web_admin(
request: Request,
current_user: str = Depends(get_current_user),
):
_, is_admin, _ = resolve_account_for_user(current_user)
if not is_admin:
return RedirectResponse(url="/", status_code=303)
show_all = request.session.get("show_all", False)
return templates.TemplateResponse(
"admin.html",
{
"request": request,
"current_user": current_user,
"show_all": show_all,
},
)
@app.post("/admin/toggle")
def admin_toggle(
request: Request,
current_user: str = Depends(get_current_user),
):
_, is_admin, _ = resolve_account_for_user(current_user)
if not is_admin:
return RedirectResponse(url="/", status_code=303)
show_all = request.session.get("show_all", False)
request.session["show_all"] = not show_all
return RedirectResponse(url="/admin", status_code=303)
@app.get("/search", response_class=HTMLResponse) @app.get("/search", response_class=HTMLResponse)
def web_search( def web_search(
request: Request, request: Request,
search: str | None = Query(default=None), search: str | None = Query(default=None),
current_user: str = Depends(get_current_user), current_user: str = Depends(get_current_user),
): ):
account_name, is_admin = resolve_account_for_user(current_user) account_name, is_admin, show_all = _resolve_effective_account(request, current_user)
with db.get_connection() as conn: with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name) where_clause, params = _account_filter_clause(account_name)
@@ -271,6 +319,7 @@ def web_search(
"request": request, "request": request,
"current_user": current_user, "current_user": current_user,
"is_admin": is_admin, "is_admin": is_admin,
"show_all": show_all,
"account_name": account_name or "alle Accounts", "account_name": account_name or "alle Accounts",
"contacts": rows, "contacts": rows,
"search": search or "", "search": search or "",
@@ -285,7 +334,7 @@ def web_contact(
search: str | None = Query(default=None), search: str | None = Query(default=None),
current_user: str = Depends(get_current_user), current_user: str = Depends(get_current_user),
): ):
account_name, is_admin = resolve_account_for_user(current_user) account_name, is_admin, show_all = _resolve_effective_account(request, current_user)
with db.get_connection() as conn: with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name) where_clause, params = _account_filter_clause(account_name)
@@ -311,6 +360,7 @@ def web_contact(
"request": request, "request": request,
"current_user": current_user, "current_user": current_user,
"is_admin": is_admin, "is_admin": is_admin,
"show_all": show_all,
"contact": contact, "contact": contact,
"search": search or "", "search": search or "",
}, },
+177
View File
@@ -0,0 +1,177 @@
<!-- Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) -->
<!-- Licensed under the MIT License. See LICENSE file in project root for details. -->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<title>Admin Kontakte</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
overflow-y: scroll;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: #222;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #0066cc;
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.back-link:hover {
text-decoration: underline;
}
.card {
background: #fff;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.card-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
margin-bottom: 1rem;
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 6px;
}
.toggle-label {
font-size: 0.875rem;
color: #333;
}
.toggle-sub {
font-size: 0.75rem;
color: #888;
margin-top: 0.25rem;
}
.toggle-switch {
position: relative;
width: 48px;
height: 26px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
inset: 0;
background: #ccc;
border-radius: 26px;
transition: background 0.2s;
}
.toggle-slider::before {
content: "";
position: absolute;
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider {
background: #0066cc;
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(22px);
}
.status {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.status-on {
background: #d4edda;
color: #155724;
}
.status-off {
background: #f8f9fa;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1><a href="/" style="text-decoration:none;color:inherit;display:inline-flex;align-items:center;gap:0.4rem"><img src="/static/favicon.svg" alt="" style="height:1.5em;width:1.5em;vertical-align:middle">Kontakte</a></h1>
<a href="/" class="back-link">← Zurück zur Übersicht</a>
<div class="card">
<div class="card-title">Admin-Einstellungen</div>
<form method="post" action="/admin/toggle">
<div class="toggle-row">
<div>
<div class="toggle-label">Alle Accounts anzeigen</div>
<div class="toggle-sub">Kontakte aller Benutzer einsehen</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="show_all" onchange="this.form.submit()" {% if show_all %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
</form>
<div class="status {% if show_all %}status-on{% else %}status-off{% endif %}">
{% if show_all %}
Aktiv: Du siehst alle Kontakte aller Accounts.
{% else %}
Inaktiv: Du siehst nur deine eigenen Kontakte.
{% endif %}
</div>
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -212,7 +212,7 @@
{% if contact.organization %}{{ contact.organization }}{% endif %} {% if contact.organization %}{{ contact.organization }}{% endif %}
</div> </div>
{% endif %} {% endif %}
{% if is_admin and contact.account %} {% if show_all and contact.account %}
<span class="badge badge-account">{{ contact.account }}</span> <span class="badge badge-account">{{ contact.account }}</span>
{% endif %} {% endif %}
</div> </div>
+1 -1
View File
@@ -219,7 +219,7 @@
<div class="meta"> <div class="meta">
Angemeldet als <strong>{{ current_user }}</strong> Angemeldet als <strong>{{ current_user }}</strong>
{% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %} {% if is_admin %}(<a href="/admin" style="color:inherit">Admin</a>{% if show_all %}, sieht alle Accounts{% endif %}){% else %}(Account: {{ account_name }}){% endif %}
</div> </div>
<form class="search-form" method="get" action="/search"> <form class="search-form" method="get" action="/search">
+1 -1
View File
@@ -162,7 +162,7 @@
<h1><a href="/" style="text-decoration:none;color:inherit;display:inline-flex;align-items:center;gap:0.4rem"><img src="/static/favicon.svg" alt="" style="height:1.5em;width:1.5em;vertical-align:middle">Kontakte</a></h1> <h1><a href="/" style="text-decoration:none;color:inherit;display:inline-flex;align-items:center;gap:0.4rem"><img src="/static/favicon.svg" alt="" style="height:1.5em;width:1.5em;vertical-align:middle">Kontakte</a></h1>
<div class="meta"> <div class="meta">
Angemeldet als <strong>{{ current_user }}</strong> Angemeldet als <strong>{{ current_user }}</strong>
{% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %} {% if is_admin %}(<a href="/admin" style="color:inherit">Admin</a>{% if show_all %}, sieht alle Accounts{% endif %}){% else %}(Account: {{ account_name }}){% endif %}
· {{ contacts|length }} Kontakte (max. 200 angezeigt) · {{ contacts|length }} Kontakte (max. 200 angezeigt)
</div> </div>