weircon-front: SSO-portal til weircon.dk (Authentik-login, app-dashboard, admin, favoritter, tema, palette)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
data/
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# weircon-front
|
||||||
|
|
||||||
|
Forside og admin-portal for **weircon.dk** — et selv-hostet homelab.
|
||||||
|
|
||||||
|
FastAPI-app der:
|
||||||
|
|
||||||
|
- **Logger ind via Authentik (OIDC)** og viser de tjenester brugeren har adgang til (hentet fra Authentiks app-liste pr. bruger), grupperet efter app-gruppe.
|
||||||
|
- **Favoritter + visnings-toggle** (grupperet/alfabetisk), gemt server-side pr. bruger.
|
||||||
|
- **⌘K kommandopalette** til hurtig søgning/navigation.
|
||||||
|
- **Lys/mørkt tema** (ført videre fra asger.weirsoe.dk).
|
||||||
|
- **Admin** (kun for medlemmer af `ADMIN_GROUP`), udvidbare moduler:
|
||||||
|
- IP -> label & land (skrives til NPMplus ip_labels.json)
|
||||||
|
- Cookie-tekster
|
||||||
|
- Matomo-statistik
|
||||||
|
- App-beskrivelser & grupper (skriver tilbage til Authentik)
|
||||||
|
|
||||||
|
## Kør
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv /opt/portal/venv
|
||||||
|
/opt/portal/venv/bin/pip install -r requirements.txt
|
||||||
|
cp deploy/portal.env.example /etc/portal.env # udfyld secrets
|
||||||
|
cp deploy/portal.service /etc/systemd/system/
|
||||||
|
systemctl enable --now portal
|
||||||
|
```
|
||||||
|
|
||||||
|
Appen kører bag NPMplus (TLS + proxy) paa weircon.dk. Tilfoej nye admin-moduler i `ADMIN_MODULES` + en route + en template der extender `base.html`.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Kopiér til /etc/portal.env og udfyld. INGEN secrets i git.
|
||||||
|
AUTHENTIK_BASE=https://authentik.weircon.dk
|
||||||
|
OIDC_CLIENT_ID=__udfyld__
|
||||||
|
OIDC_CLIENT_SECRET=__udfyld__
|
||||||
|
AUTHENTIK_API_TOKEN=__service-account-token__
|
||||||
|
SESSION_SECRET=__tilfaeldig-lang-streng__
|
||||||
|
OIDC_REDIRECT_URI=https://weircon.dk/auth/callback
|
||||||
|
ADMIN_GROUP=authentik Admins
|
||||||
|
MATOMO_BASE=https://matomo.weircon.dk
|
||||||
|
MATOMO_TOKEN=__matomo-token__
|
||||||
|
MATOMO_SITE=2
|
||||||
|
IP_LABELS_REMOTE=root@192.168.10.201:/opt/npmplus/custom_nginx/ip_labels.json
|
||||||
|
PORTAL_DATA=/opt/portal/data
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Weircon Portal (SSO dashboard + admin)
|
||||||
|
After=network-online.target
|
||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/portal.env
|
||||||
|
WorkingDirectory=/opt/portal/app
|
||||||
|
ExecStart=/opt/portal/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=*
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
"""Weircon Portal — forside (SSO-dashboard) + udvidbar admin.
|
||||||
|
|
||||||
|
- Login via Authentik (OIDC). Dashboard viser de apps brugeren har adgang til.
|
||||||
|
- Admin (kun for medlemmer af ADMIN_GROUP): IP→label-mapping, cookie-tekster, statistik.
|
||||||
|
- Admin-moduler er registreret i ADMIN_MODULES — tilføj nye ved at registrere en post.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
from fastapi import FastAPI, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
BASE = Path(__file__).resolve().parent
|
||||||
|
DATA = Path(os.environ.get("PORTAL_DATA", "/opt/portal/data"))
|
||||||
|
DATA.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
AUTHENTIK = os.environ["AUTHENTIK_BASE"].rstrip("/")
|
||||||
|
CLIENT_ID = os.environ["OIDC_CLIENT_ID"]
|
||||||
|
CLIENT_SECRET = os.environ["OIDC_CLIENT_SECRET"]
|
||||||
|
API_TOKEN = os.environ["AUTHENTIK_API_TOKEN"]
|
||||||
|
SECRET_KEY = os.environ["SESSION_SECRET"]
|
||||||
|
REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "https://weircon.dk/auth/callback")
|
||||||
|
ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins")
|
||||||
|
MATOMO_BASE = os.environ.get("MATOMO_BASE", "https://matomo.weircon.dk").rstrip("/")
|
||||||
|
MATOMO_TOKEN = os.environ.get("MATOMO_TOKEN", "")
|
||||||
|
MATOMO_SITE = os.environ.get("MATOMO_SITE", "2")
|
||||||
|
# Sync af IP-labels til NPMplus (CT101) efter hver gemning
|
||||||
|
IP_LABELS_LOCAL = DATA / "ip_labels.json"
|
||||||
|
IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "") # fx root@192.168.10.201:/opt/npmplus/custom_nginx/ip_labels.json
|
||||||
|
SETTINGS_PATH = DATA / "settings.json"
|
||||||
|
|
||||||
|
app = FastAPI(title="Weircon Portal")
|
||||||
|
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, https_only=True, same_site="lax", max_age=60 * 60 * 12)
|
||||||
|
app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
|
||||||
|
templates = Jinja2Templates(directory=str(BASE / "templates"))
|
||||||
|
|
||||||
|
oauth = OAuth()
|
||||||
|
oauth.register(
|
||||||
|
name="authentik",
|
||||||
|
server_metadata_url=f"{AUTHENTIK}/application/o/weircon-portal/.well-known/openid-configuration",
|
||||||
|
client_id=CLIENT_ID,
|
||||||
|
client_secret=CLIENT_SECRET,
|
||||||
|
client_kwargs={"scope": "openid email profile groups"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- udvidbare admin-moduler: tilføj en post for at få en ny admin-side ----
|
||||||
|
ADMIN_MODULES = [
|
||||||
|
{"key": "ip-labels", "title": "IP → label & land", "icon": "🌍",
|
||||||
|
"desc": "Mærk kendte IP-adresser (netværk/land) i statistikken."},
|
||||||
|
{"key": "cookies", "title": "Cookie-tekster", "icon": "🍪",
|
||||||
|
"desc": "Teksten på cookie-banneret og /cookies-siden."},
|
||||||
|
{"key": "stats", "title": "Statistik", "icon": "📊",
|
||||||
|
"desc": "Hurtigt overblik fra Matomo."},
|
||||||
|
{"key": "apps", "title": "App-beskrivelser & grupper", "icon": "🧩",
|
||||||
|
"desc": "Rediger beskrivelse og gruppe på dine Authentik-apps."},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def current_user(request: Request):
|
||||||
|
return request.session.get("user")
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user) -> bool:
|
||||||
|
return bool(user) and ADMIN_GROUP in (user.get("groups") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path, default):
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def save_ip_labels(data: dict) -> str:
|
||||||
|
IP_LABELS_LOCAL.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
|
||||||
|
if IP_LABELS_REMOTE:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["scp", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=8",
|
||||||
|
str(IP_LABELS_LOCAL), IP_LABELS_REMOTE],
|
||||||
|
check=True, capture_output=True, timeout=20)
|
||||||
|
return "gemt + synkroniseret til NPMplus"
|
||||||
|
except Exception as e:
|
||||||
|
return f"gemt lokalt, men sync til NPMplus fejlede: {e}"
|
||||||
|
return "gemt lokalt"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_user_apps(username: str):
|
||||||
|
"""Apps brugeren har adgang til, via Authentik-API (service-account-token)."""
|
||||||
|
headers = {"Authorization": f"Bearer {API_TOKEN}"}
|
||||||
|
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=headers, timeout=10) as c:
|
||||||
|
u = await c.get("/api/v3/core/users/", params={"username": username})
|
||||||
|
results = u.json().get("results", [])
|
||||||
|
if not results:
|
||||||
|
return []
|
||||||
|
pk = results[0]["pk"]
|
||||||
|
a = await c.get("/api/v3/core/applications/",
|
||||||
|
params={"for_user": pk, "superuser_full_list": "false", "page_size": 100})
|
||||||
|
apps = a.json().get("results", [])
|
||||||
|
out = []
|
||||||
|
for app_ in apps:
|
||||||
|
if app_.get("slug") == "weircon-portal":
|
||||||
|
continue
|
||||||
|
url = app_.get("launch_url") or app_.get("meta_launch_url")
|
||||||
|
if not url:
|
||||||
|
continue # kun apps helt uden launch-URL (authentik-cli, ldap-auth) — vises heller ikke i Authentik
|
||||||
|
host = url.split("//", 1)[-1].split("/", 1)[0]
|
||||||
|
out.append({
|
||||||
|
"slug": app_.get("slug"),
|
||||||
|
"name": app_.get("name"),
|
||||||
|
"url": url,
|
||||||
|
"host": host,
|
||||||
|
"group": (app_.get("group") or "").strip(),
|
||||||
|
"desc": (app_.get("meta_description") or "").strip(),
|
||||||
|
"icon": app_.get("meta_icon") or "",
|
||||||
|
})
|
||||||
|
out.sort(key=lambda x: (x["name"] or "").lower())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
GROUP_ORDER = {n: i for i, n in enumerate(
|
||||||
|
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
|
||||||
|
|
||||||
|
|
||||||
|
USERPREFS = DATA / "userprefs.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_prefs(username):
|
||||||
|
p = (load_json(USERPREFS, {}) or {}).get(username) or {}
|
||||||
|
return {"favorites": p.get("favorites") or [], "view": p.get("view") or "grouped"}
|
||||||
|
|
||||||
|
|
||||||
|
def set_prefs(username, **kw):
|
||||||
|
allp = load_json(USERPREFS, {}) or {}
|
||||||
|
cur = allp.get(username) or {}
|
||||||
|
cur.update(kw)
|
||||||
|
allp[username] = cur
|
||||||
|
USERPREFS.write_text(json.dumps(allp, ensure_ascii=False, indent=2), "utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def group_apps(apps):
|
||||||
|
"""Grupperer apps efter Authentiks group-felt, i en fornuftig rækkefølge."""
|
||||||
|
groups = {}
|
||||||
|
for a in apps:
|
||||||
|
g = a.get("group") or "Øvrige"
|
||||||
|
groups.setdefault(g, []).append(a)
|
||||||
|
|
||||||
|
def gkey(name):
|
||||||
|
if name == "Øvrige":
|
||||||
|
return (999, "")
|
||||||
|
return (GROUP_ORDER.get(name, 50), name.lower())
|
||||||
|
|
||||||
|
return [{"name": g, "apps": sorted(groups[g], key=lambda x: (x["name"] or "").lower())}
|
||||||
|
for g in sorted(groups, key=gkey)]
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_all_apps_admin():
|
||||||
|
"""Alle apps (admin-syn) med slug/navn/gruppe/beskrivelse."""
|
||||||
|
headers = {"Authorization": f"Bearer {API_TOKEN}"}
|
||||||
|
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=headers, timeout=15) as c:
|
||||||
|
r = await c.get("/api/v3/core/applications/",
|
||||||
|
params={"page_size": 100, "superuser_full_list": "true"})
|
||||||
|
apps = r.json().get("results", [])
|
||||||
|
out = [{"slug": a["slug"], "name": a.get("name"),
|
||||||
|
"group": a.get("group") or "", "desc": (a.get("meta_description") or "").strip()}
|
||||||
|
for a in apps if a.get("slug") != "weircon-portal"]
|
||||||
|
out.sort(key=lambda x: ((x["group"] or "~~").lower(), (x["name"] or "").lower()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def patch_apps(rows):
|
||||||
|
headers = {"Authorization": f"Bearer {API_TOKEN}"}
|
||||||
|
n = 0
|
||||||
|
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=headers, timeout=20) as c:
|
||||||
|
for r in rows:
|
||||||
|
resp = await c.patch(f"/api/v3/core/applications/{r['slug']}/",
|
||||||
|
json={"meta_description": r["desc"], "group": r["group"]})
|
||||||
|
if resp.status_code < 300:
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
async def matomo_summary():
|
||||||
|
if not MATOMO_TOKEN:
|
||||||
|
return None
|
||||||
|
params = {
|
||||||
|
"module": "API", "method": "VisitsSummary.get", "idSite": MATOMO_SITE,
|
||||||
|
"period": "day", "date": "last7", "format": "JSON", "token_auth": MATOMO_TOKEN,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.get(f"{MATOMO_BASE}/index.php", params=params)
|
||||||
|
data = r.json()
|
||||||
|
days = list(data.values()) if isinstance(data, dict) else []
|
||||||
|
visits = sum(int(d.get("nb_visits", 0) or 0) for d in days if isinstance(d, dict))
|
||||||
|
actions = sum(int(d.get("nb_actions", 0) or 0) for d in days if isinstance(d, dict))
|
||||||
|
return {"visits_7d": visits, "actions_7d": actions}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- ruter ----------------
|
||||||
|
@app.get("/auth/login")
|
||||||
|
async def login(request: Request):
|
||||||
|
return await oauth.authentik.authorize_redirect(request, REDIRECT_URI)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/callback")
|
||||||
|
async def callback(request: Request):
|
||||||
|
try:
|
||||||
|
token = await oauth.authentik.authorize_access_token(request)
|
||||||
|
except Exception as e:
|
||||||
|
return HTMLResponse(f"Login fejlede: {e}", status_code=400)
|
||||||
|
info = token.get("userinfo") or {}
|
||||||
|
request.session["user"] = {
|
||||||
|
"name": info.get("name") or info.get("preferred_username"),
|
||||||
|
"username": info.get("preferred_username"),
|
||||||
|
"email": info.get("email"),
|
||||||
|
"groups": info.get("groups") or [],
|
||||||
|
}
|
||||||
|
return RedirectResponse("/", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse("/", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def home(request: Request):
|
||||||
|
user = current_user(request)
|
||||||
|
if not user:
|
||||||
|
return templates.TemplateResponse(request, "login.html", {"request": request})
|
||||||
|
apps, error = [], None
|
||||||
|
try:
|
||||||
|
apps = await fetch_user_apps(user["username"])
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Kunne ikke hente apps fra Authentik: {e}"
|
||||||
|
prefs = get_prefs(user["username"])
|
||||||
|
fav_set = set(prefs["favorites"])
|
||||||
|
by_name = sorted(apps, key=lambda x: (x["name"] or "").lower())
|
||||||
|
return templates.TemplateResponse(request, "dashboard.html", {
|
||||||
|
"request": request, "user": user, "error": error, "is_admin": is_admin(user),
|
||||||
|
"groups": group_apps(apps), "apps_sorted": by_name,
|
||||||
|
"fav_apps": [a for a in by_name if a["slug"] in fav_set],
|
||||||
|
"favorites": list(fav_set), "view": prefs["view"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin", response_class=HTMLResponse)
|
||||||
|
async def admin_home(request: Request):
|
||||||
|
user = current_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Kun for administratorer")
|
||||||
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
|
"request": request, "user": user, "modules": ADMIN_MODULES, "is_admin": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/ip-labels", response_class=HTMLResponse)
|
||||||
|
async def admin_iplabels(request: Request, saved: str = ""):
|
||||||
|
user = current_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Kun for administratorer")
|
||||||
|
raw = IP_LABELS_LOCAL.read_text("utf-8") if IP_LABELS_LOCAL.exists() else "{}"
|
||||||
|
data = load_json(IP_LABELS_LOCAL, {})
|
||||||
|
rows = [{"ip": k, "label": (v or {}).get("label", ""), "country": (v or {}).get("country", "")}
|
||||||
|
for k, v in data.items() if isinstance(v, dict)]
|
||||||
|
rows.sort(key=lambda r: r["ip"])
|
||||||
|
return templates.TemplateResponse(request, "admin_iplabels.html", {
|
||||||
|
"request": request, "user": user, "is_admin": True,
|
||||||
|
"rows": rows, "raw": raw, "saved": saved,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/ip-labels")
|
||||||
|
async def admin_iplabels_save(request: Request):
|
||||||
|
user = current_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Kun for administratorer")
|
||||||
|
form = await request.form()
|
||||||
|
ips = form.getlist("ip")
|
||||||
|
labels = form.getlist("label")
|
||||||
|
countries = form.getlist("country")
|
||||||
|
data = {}
|
||||||
|
for ip, label, country in zip(ips, labels, countries):
|
||||||
|
ip = (ip or "").strip()
|
||||||
|
if not ip:
|
||||||
|
continue
|
||||||
|
entry = {}
|
||||||
|
if (label or "").strip():
|
||||||
|
entry["label"] = label.strip()
|
||||||
|
if (country or "").strip():
|
||||||
|
entry["country"] = country.strip().lower()
|
||||||
|
if entry:
|
||||||
|
data[ip] = entry
|
||||||
|
msg = save_ip_labels(data)
|
||||||
|
return RedirectResponse(f"/admin/ip-labels?saved={msg}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/cookies", response_class=HTMLResponse)
|
||||||
|
async def admin_cookies(request: Request, saved: str = ""):
|
||||||
|
user = current_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Kun for administratorer")
|
||||||
|
s = load_json(SETTINGS_PATH, {})
|
||||||
|
return templates.TemplateResponse(request, "admin_cookies.html", {
|
||||||
|
"request": request, "user": user, "is_admin": True,
|
||||||
|
"banner": s.get("cookie_banner", "Denne side bruger cookies til at forbedre din oplevelse og lave anonym statistik over besøg. Ved at bruge siden accepterer du det."),
|
||||||
|
"page": s.get("cookie_page", "Vi bruger Matomo (self-hosted) til anonym statistik og et par funktionelle cookies. Intet deles med tredjeparter."),
|
||||||
|
"saved": saved,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/cookies")
|
||||||
|
async def admin_cookies_save(request: Request, cookie_banner: str = Form(""), cookie_page: str = Form("")):
|
||||||
|
user = current_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Kun for administratorer")
|
||||||
|
s = load_json(SETTINGS_PATH, {})
|
||||||
|
s["cookie_banner"] = cookie_banner
|
||||||
|
s["cookie_page"] = cookie_page
|
||||||
|
SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), "utf-8")
|
||||||
|
return RedirectResponse("/admin/cookies?saved=Gemt", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/stats", response_class=HTMLResponse)
|
||||||
|
async def admin_stats(request: Request):
|
||||||
|
user = current_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Kun for administratorer")
|
||||||
|
stats = await matomo_summary()
|
||||||
|
return templates.TemplateResponse(request, "admin_stats.html", {
|
||||||
|
"request": request, "user": user, "is_admin": True,
|
||||||
|
"stats": stats, "matomo": MATOMO_BASE,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/apps", response_class=HTMLResponse)
|
||||||
|
async def admin_apps(request: Request, saved: str = ""):
|
||||||
|
user = current_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Kun for administratorer")
|
||||||
|
rows = await fetch_all_apps_admin()
|
||||||
|
return templates.TemplateResponse(request, "admin_apps.html", {
|
||||||
|
"request": request, "user": user, "is_admin": True, "rows": rows, "saved": saved,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/apps")
|
||||||
|
async def admin_apps_save(request: Request):
|
||||||
|
user = current_user(request)
|
||||||
|
if not is_admin(user):
|
||||||
|
raise HTTPException(403, "Kun for administratorer")
|
||||||
|
form = await request.form()
|
||||||
|
slugs = form.getlist("slug")
|
||||||
|
descs = form.getlist("desc")
|
||||||
|
groups = form.getlist("group")
|
||||||
|
rows = [{"slug": sl, "desc": (d or "").strip(), "group": (g or "").strip()}
|
||||||
|
for sl, d, g in zip(slugs, descs, groups) if sl]
|
||||||
|
n = await patch_apps(rows)
|
||||||
|
return RedirectResponse(f"/admin/apps?saved=Gemt {n} apps", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# Offentligt JSON-endpoint så frontend/andre kan læse cookie-teksterne (valgfrit)
|
||||||
|
@app.get("/api/settings")
|
||||||
|
async def api_settings():
|
||||||
|
return JSONResponse(load_json(SETTINGS_PATH, {}))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/favorite")
|
||||||
|
async def api_favorite(request: Request):
|
||||||
|
user = current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401)
|
||||||
|
body = await request.json()
|
||||||
|
slug = str(body.get("slug") or "")
|
||||||
|
favs = set(get_prefs(user["username"])["favorites"])
|
||||||
|
favs.add(slug) if body.get("on") else favs.discard(slug)
|
||||||
|
set_prefs(user["username"], favorites=sorted(favs))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/view")
|
||||||
|
async def api_view(request: Request):
|
||||||
|
user = current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401)
|
||||||
|
v = (await request.json()).get("view")
|
||||||
|
if v in ("grouped", "alpha"):
|
||||||
|
set_prefs(user["username"], view=v)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/search-index")
|
||||||
|
async def search_index(request: Request):
|
||||||
|
"""Søgeindeks til ⌘K-paletten: brugerens apps + sider."""
|
||||||
|
user = current_user(request)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse([])
|
||||||
|
items = [{"type": "page", "title": "Forside", "url": "/"}]
|
||||||
|
if is_admin(user):
|
||||||
|
items += [
|
||||||
|
{"type": "page", "title": "Admin-panel", "url": "/admin", "extra": "admin indstillinger"},
|
||||||
|
{"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip land netværk geo"},
|
||||||
|
{"type": "page", "title": "Cookie-tekster", "url": "/admin/cookies", "extra": "cookies samtykke"},
|
||||||
|
{"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo besøg"},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for a in await fetch_user_apps(user["username"]):
|
||||||
|
items.append({"type": "app", "title": a["name"], "url": a["url"],
|
||||||
|
"extra": f'{a["desc"]} {a["host"]}'.strip()})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return JSONResponse(items)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz():
|
||||||
|
return {"ok": True, "configured": bool(CLIENT_ID and API_TOKEN)}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
jinja2
|
||||||
|
authlib
|
||||||
|
httpx
|
||||||
|
itsdangerous
|
||||||
|
python-multipart
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Weircon — admin{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Admin-panel</h1>
|
||||||
|
<p class="lead">Indstillinger for homelab'et. Tilføj nye moduler i <code>ADMIN_MODULES</code>.</p>
|
||||||
|
<div class="grid" style="margin-top:1.6rem">
|
||||||
|
{% for m in modules %}
|
||||||
|
<a class="card" href="/admin/{{ m.key }}">
|
||||||
|
<div class="head"><span class="ico">{{ m.icon }}</span><span class="name">{{ m.title }}</span></div>
|
||||||
|
<div class="sub">{{ m.desc }}</div>
|
||||||
|
<div class="go">Åbn →</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin — app-beskrivelser{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>App-beskrivelser & grupper</h1>
|
||||||
|
<p class="lead">Beskrivelsen og gruppen kommer fra hver apps felter i Authentik. Rediger her — det gemmes tilbage i Authentik og slår igennem på både portalen og Authentiks egen side.</p>
|
||||||
|
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
|
||||||
|
<form method="post" action="/admin/apps">
|
||||||
|
<table style="margin-top:1rem">
|
||||||
|
<thead><tr><th style="width:24%">App</th><th style="width:18%">Gruppe</th><th>Beskrivelse</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.name }}<input type="hidden" name="slug" value="{{ r.slug }}"></td>
|
||||||
|
<td><input name="group" value="{{ r.group }}" placeholder="fx Home" list="groups"></td>
|
||||||
|
<td><input name="desc" value="{{ r.desc }}" placeholder="kort beskrivelse"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<datalist id="groups">
|
||||||
|
{% for g in rows|map(attribute='group')|reject('equalto','')|unique %}<option value="{{ g }}">{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
<p style="margin-top:1.1rem"><button class="btn" type="submit">Gem alle</button></p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin — cookies{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Cookie-tekster</h1>
|
||||||
|
<p class="lead">Teksterne der vises i cookie-banneret og på /cookies. (Frontend kan læse dem fra <code>/api/settings</code>.)</p>
|
||||||
|
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
|
||||||
|
<form method="post" action="/admin/cookies">
|
||||||
|
<h2>Banner-tekst</h2>
|
||||||
|
<textarea name="cookie_banner">{{ banner }}</textarea>
|
||||||
|
<h2>Tekst på /cookies</h2>
|
||||||
|
<textarea name="cookie_page">{{ page }}</textarea>
|
||||||
|
<p style="margin-top:1rem"><button class="btn" type="submit">Gem</button></p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin — IP-labels{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>IP → label & land</h1>
|
||||||
|
<p class="lead">Mærk kendte IP-adresser. Nøgle = præcis IP eller præfiks (fx <code>192.168.</code>). Land = ISO-kode (fx <code>dk</code>) der overstyrer geolokation. Gemmes live til NPMplus.</p>
|
||||||
|
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
|
||||||
|
<form method="post" action="/admin/ip-labels">
|
||||||
|
<table id="tbl" style="margin-top:1rem">
|
||||||
|
<thead><tr><th style="width:40%">IP / præfiks</th><th>Label</th><th style="width:90px">Land</th><th style="width:40px"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr><td><input name="ip" value="{{ r.ip }}"></td><td><input name="label" value="{{ r.label }}"></td><td><input name="country" value="{{ r.country }}"></td><td><button type="button" class="btn secondary" onclick="this.closest('tr').remove()">✕</button></td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr><td><input name="ip" placeholder="fx 192.168."></td><td><input name="label" placeholder="Hjemme"></td><td><input name="country" placeholder="dk"></td><td><button type="button" class="btn secondary" onclick="this.closest('tr').remove()">✕</button></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="margin-top:1rem;display:flex;gap:.6rem">
|
||||||
|
<button type="button" class="btn secondary" onclick="addRow()">+ Tilføj række</button>
|
||||||
|
<button type="submit" class="btn">Gem</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
function addRow(){var t=document.querySelector('#tbl tbody');var tr=document.createElement('tr');tr.innerHTML='<td><input name="ip"></td><td><input name="label"></td><td><input name="country"></td><td><button type="button" class="btn secondary" onclick="this.closest(\'tr\').remove()">✕</button></td>';t.appendChild(tr);}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin — statistik{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Statistik</h1>
|
||||||
|
<p class="lead">Hurtigt overblik fra Matomo (sidste 7 dage).</p>
|
||||||
|
{% if stats %}
|
||||||
|
<div class="grid" style="margin-top:1.5rem">
|
||||||
|
<div class="stat"><div class="n">{{ stats.visits_7d }}</div><div class="muted">besøg (7 dage)</div></div>
|
||||||
|
<div class="stat"><div class="n">{{ stats.actions_7d }}</div><div class="muted">handlinger (7 dage)</div></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:1.5rem"><a class="btn secondary" href="{{ matomo }}">Åbn Matomo →</a></p>
|
||||||
|
{% else %}
|
||||||
|
<div class="err">Kunne ikke hente data fra Matomo (mangler token eller forbindelse).</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="da" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#0b0f17" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta name="theme-color" content="#f6f8fb" media="(prefers-color-scheme: light)" />
|
||||||
|
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml" />
|
||||||
|
<title>{% block title %}Weircon{% endblock %}</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('theme') ||
|
||||||
|
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||||
|
document.documentElement.dataset.theme = t;
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var b = e.target.closest('[data-theme-toggle]');
|
||||||
|
if (!b) return;
|
||||||
|
var next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
||||||
|
document.documentElement.dataset.theme = next;
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter Variable"; font-style: normal; font-weight: 100 900;
|
||||||
|
font-display: swap; src: url("/static/fonts/inter.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
:root, html[data-theme="dark"] {
|
||||||
|
--bg:#0b0f17; --surface:#121a27; --surface2:#182232; --border:#243044;
|
||||||
|
--text:#e6e9ef; --muted:#97a3b6; --accent:#38bdf8; --accent2:#818cf8;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg:#f6f8fb; --surface:#ffffff; --surface2:#eef2f8; --border:#dde4ee;
|
||||||
|
--text:#0f1729; --muted:#54617a; --accent:#0284c7; --accent2:#4f46e5;
|
||||||
|
}
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
html { scroll-behavior:smooth; color-scheme:dark; }
|
||||||
|
html[data-theme="light"] { color-scheme:light; }
|
||||||
|
body {
|
||||||
|
margin:0; min-height:100vh; display:flex; flex-direction:column;
|
||||||
|
background:var(--bg); color:var(--text);
|
||||||
|
font:16px/1.7 "Inter Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
body, a, span, div, p, h1, h2, .card, input, button, nav { transition: background-color .35s ease, border-color .35s ease, color .35s ease; }
|
||||||
|
body::before {
|
||||||
|
content:""; position:fixed; inset:0; z-index:-1; pointer-events:none;
|
||||||
|
background:
|
||||||
|
radial-gradient(60rem 30rem at 70% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%),
|
||||||
|
radial-gradient(50rem 30rem at 10% 0%, color-mix(in srgb, var(--accent2) 10%, transparent), transparent 70%);
|
||||||
|
}
|
||||||
|
::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||||
|
a { color:inherit; }
|
||||||
|
.wrap { width:100%; max-width:960px; margin:0 auto; padding:2.5rem 1.25rem 4rem; flex:1; }
|
||||||
|
/* nav */
|
||||||
|
nav { display:flex; align-items:center; gap:1.1rem; width:100%; max-width:960px; margin:0 auto; padding:1.1rem 1.25rem; }
|
||||||
|
nav .brand { font-weight:800; font-size:1.15rem; letter-spacing:-.02em; }
|
||||||
|
nav .brand .dot { color:var(--accent); }
|
||||||
|
nav .links { display:flex; gap:1.1rem; }
|
||||||
|
nav a { text-decoration:none; color:var(--muted); font-size:.95rem; font-weight:500; }
|
||||||
|
nav a:hover { color:var(--text); }
|
||||||
|
nav .spacer { flex:1; }
|
||||||
|
nav .who { color:var(--muted); font-size:.9rem; }
|
||||||
|
/* typografi */
|
||||||
|
h1 { font-size:2.2rem; letter-spacing:-.025em; margin:.6rem 0 .35rem; font-weight:800; }
|
||||||
|
h2 { font-size:.8rem; text-transform:uppercase; letter-spacing:.14em; color:var(--muted); margin:2.6rem 0 1rem; font-weight:600; }
|
||||||
|
.lead { color:var(--muted); font-size:1.05rem; max-width:46rem; }
|
||||||
|
/* filter */
|
||||||
|
.filter { margin:1.4rem 0 .2rem; position:relative; max-width:22rem; }
|
||||||
|
.filter input { padding-left:2.2rem; }
|
||||||
|
.filter .mag { position:absolute; left:.7rem; top:50%; transform:translateY(-50%); color:var(--muted); }
|
||||||
|
/* card-grid med ENS størrelser */
|
||||||
|
.grid { display:grid; gap:.9rem; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); margin-top:1rem; }
|
||||||
|
.card {
|
||||||
|
display:flex; flex-direction:column; min-height:7.25rem; height:100%;
|
||||||
|
background:color-mix(in srgb, var(--surface) 70%, transparent);
|
||||||
|
border:1px solid var(--border); border-radius:1rem; padding:1.1rem 1.15rem;
|
||||||
|
text-decoration:none; color:inherit;
|
||||||
|
transition:border-color .18s ease, transform .18s ease, background .18s ease;
|
||||||
|
}
|
||||||
|
a.card:hover { border-color:var(--accent); transform:translateY(-3px);
|
||||||
|
background:color-mix(in srgb, var(--surface) 92%, transparent); }
|
||||||
|
.card.hidden { display:none; }
|
||||||
|
.card .head { display:flex; align-items:center; gap:.65rem; }
|
||||||
|
.card .ico {
|
||||||
|
flex:0 0 auto; width:2.1rem; height:2.1rem; border-radius:.6rem;
|
||||||
|
display:grid; place-items:center; font-weight:700; font-size:1rem;
|
||||||
|
background:color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
color:var(--accent); overflow:hidden;
|
||||||
|
}
|
||||||
|
.card .ico img { width:100%; height:100%; object-fit:contain; }
|
||||||
|
.card .name { font-weight:600; letter-spacing:-.01em; line-height:1.25; }
|
||||||
|
.card .sub { color:var(--muted); font-size:.86rem; margin-top:.6rem; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.card .go { color:var(--accent); font-size:.8rem; margin-top:.7rem; opacity:.85; }
|
||||||
|
/* knapper / forms */
|
||||||
|
.btn { display:inline-block; background:var(--accent); color:#06101f; font-weight:600;
|
||||||
|
text-decoration:none; padding:.6rem 1.15rem; border-radius:.7rem; border:0; cursor:pointer; font:inherit; font-size:.95rem; }
|
||||||
|
.btn:hover { filter:brightness(1.08); }
|
||||||
|
.btn.secondary { background:var(--surface2); color:var(--text); border:1px solid var(--border); }
|
||||||
|
table { width:100%; border-collapse:collapse; }
|
||||||
|
th, td { border:1px solid var(--border); padding:.45rem .55rem; text-align:left; }
|
||||||
|
th { color:var(--muted); font-weight:600; font-size:.8rem; text-transform:uppercase; letter-spacing:.06em; }
|
||||||
|
input, textarea { width:100%; background:var(--surface2); border:1px solid var(--border); color:var(--text);
|
||||||
|
border-radius:.55rem; padding:.5rem .65rem; font:inherit; }
|
||||||
|
input:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||||
|
textarea { min-height:5rem; resize:vertical; }
|
||||||
|
.flash { background:color-mix(in srgb, var(--accent) 12%, transparent); border:1px solid var(--accent);
|
||||||
|
color:var(--accent); border-radius:.7rem; padding:.65rem .95rem; margin:1rem 0; font-size:.9rem; }
|
||||||
|
.err { background:color-mix(in srgb, #f5a623 14%, transparent); border:1px solid #f5a623;
|
||||||
|
color:#f5a623; border-radius:.7rem; padding:.65rem .95rem; margin:1rem 0; font-size:.9rem; }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
code { background:var(--surface2); padding:.12em .42em; border-radius:.35rem; font-size:.85em; }
|
||||||
|
.stat { background:color-mix(in srgb, var(--surface) 70%, transparent); border:1px solid var(--border); border-radius:1rem; padding:1.3rem; }
|
||||||
|
.stat .n { font-size:2.1rem; font-weight:800; letter-spacing:-.02em; }
|
||||||
|
/* footer */
|
||||||
|
.foot { width:100%; max-width:960px; margin:0 auto; padding:1.6rem 1.25rem 2.5rem; color:var(--muted); font-size:.85rem; border-top:1px solid var(--border); }
|
||||||
|
.foot a { color:var(--accent); text-decoration:none; }
|
||||||
|
/* tema-toggle (sol/måne) — ført videre fra asger.weirsøe.dk */
|
||||||
|
.theme-toggle { display:inline-grid; place-items:center; width:2.25rem; height:2.25rem; border-radius:.75rem;
|
||||||
|
color:var(--muted); background:transparent; cursor:pointer; border:1px solid transparent; }
|
||||||
|
.theme-toggle:hover { color:var(--text); background:var(--surface2); }
|
||||||
|
.theme-toggle:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||||
|
.sun-and-moon > :is(.moon, .sun, .sun-beams) { transform-origin:center; }
|
||||||
|
.sun-and-moon > :is(.moon, .sun) { fill:currentColor; }
|
||||||
|
.sun-and-moon > .sun-beams { stroke:currentColor; stroke-width:2px; }
|
||||||
|
.sun-and-moon > .sun { transition:transform .5s cubic-bezier(.5,1.25,.75,1.25); }
|
||||||
|
.sun-and-moon > .sun-beams { transition:transform .5s cubic-bezier(.5,1.5,.75,1.25), opacity .5s cubic-bezier(.25,0,.3,1); }
|
||||||
|
.sun-and-moon .moon > circle { transition:transform .25s cubic-bezier(0,0,0,1); }
|
||||||
|
@supports (cx:1) { .sun-and-moon .moon > circle { transition:cx .25s cubic-bezier(0,0,0,1); } }
|
||||||
|
[data-theme="dark"] .sun-and-moon > .sun { transform:scale(1.75); transition-timing-function:cubic-bezier(.25,0,.3,1); transition-duration:.25s; }
|
||||||
|
[data-theme="dark"] .sun-and-moon > .sun-beams { opacity:0; transform:rotateZ(-25deg); transition-duration:.15s; }
|
||||||
|
[data-theme="dark"] .sun-and-moon > .moon > circle { transform:translateX(-7px); transition-delay:.25s; transition-duration:.5s; }
|
||||||
|
@supports (cx:1) { [data-theme="dark"] .sun-and-moon > .moon > circle { cx:17; transform:translateX(0); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .sun-and-moon *, body, .card { transition:none !important; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<span class="brand">weircon<span class="dot">.</span></span>
|
||||||
|
<div class="links">
|
||||||
|
<a href="/">Forside</a>
|
||||||
|
{% if is_admin %}<a href="/admin">Admin</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
{% if user %}<span class="who">{{ user.name }}</span> <a href="/auth/logout">Log ud</a>{% endif %}
|
||||||
|
<button type="button" data-theme-toggle class="theme-toggle" title="Skift mellem lyst og mørkt tema" aria-label="Skift tema">
|
||||||
|
<svg class="sun-and-moon" aria-hidden="true" width="22" height="22" viewBox="0 0 24 24">
|
||||||
|
<mask class="moon" id="moon-mask"><rect x="0" y="0" width="100%" height="100%" fill="white" /><circle cx="24" cy="10" r="6" fill="black" /></mask>
|
||||||
|
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
|
||||||
|
<g class="sun-beams" stroke="currentColor">
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" />
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div class="wrap">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<footer class="foot">
|
||||||
|
Driftet af Asger Weirsøe · self-hosted på Proxmox · <a href="mailto:asger@weirsoe.dk">asger@weirsoe.dk</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- ⌘K / Ctrl+K kommandopalette — ført videre fra asger.weirsøe.dk, tilpasset portalens apps -->
|
||||||
|
<div id="palette" class="palette-overlay" hidden>
|
||||||
|
<div class="palette-box" role="dialog" aria-modal="true" aria-label="Kommandopalette">
|
||||||
|
<input id="palette-input" class="palette-input" type="text" placeholder="Søg tjenester, sider eller kommandoer…" autocomplete="off" spellcheck="false" />
|
||||||
|
<ul id="palette-list" class="palette-list" role="listbox"></ul>
|
||||||
|
<div class="palette-hint">↑↓ navigér · ↵ vælg · esc luk · ⌘/Ctrl K åbn</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.palette-overlay { position:fixed; inset:0; z-index:100;
|
||||||
|
background:color-mix(in srgb, var(--bg) 60%, transparent); backdrop-filter:blur(4px);
|
||||||
|
display:flex; align-items:flex-start; justify-content:center; padding-top:15vh; }
|
||||||
|
.palette-overlay[hidden] { display:none; }
|
||||||
|
.palette-box { width:min(36rem, calc(100vw - 2rem)); border:1px solid var(--border); border-radius:1rem;
|
||||||
|
background:var(--surface); box-shadow:0 24px 64px rgb(0 0 0 / .45); overflow:hidden; }
|
||||||
|
.palette-input { width:100%; padding:.9rem 1.1rem; font-size:1rem; background:transparent; color:var(--text);
|
||||||
|
border:0; outline:none; border-bottom:1px solid var(--border); }
|
||||||
|
.palette-list { max-height:50vh; overflow-y:auto; padding:.35rem; margin:0; list-style:none; }
|
||||||
|
.palette-item { display:flex; justify-content:space-between; align-items:center; gap:1rem;
|
||||||
|
padding:.55rem .8rem; border-radius:.6rem; cursor:pointer; font-size:.925rem; }
|
||||||
|
.palette-item.is-selected, .palette-item:hover:not(.palette-empty) { background:var(--surface2); }
|
||||||
|
.palette-empty { color:var(--muted); cursor:default; }
|
||||||
|
.palette-badge { font-size:.7rem; color:var(--muted); border:1px solid var(--border); border-radius:999px; padding:.05rem .5rem; }
|
||||||
|
.palette-hint { padding:.5rem 1.1rem; font-size:.72rem; color:var(--muted); border-top:1px solid var(--border); }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (window.__palette) return; window.__palette = true;
|
||||||
|
var index = null, items = [], sel = 0;
|
||||||
|
var staticActions = [
|
||||||
|
{ type:'action', title:'Tema: mørkt', run:function(){ setTheme('dark'); } },
|
||||||
|
{ type:'action', title:'Tema: lyst', run:function(){ setTheme('light'); } },
|
||||||
|
{ type:'action', title:'Log ud', url:'/auth/logout' },
|
||||||
|
];
|
||||||
|
function setTheme(t){ document.documentElement.dataset.theme = t; localStorage.setItem('theme', t); }
|
||||||
|
function el(id){ return document.getElementById(id); }
|
||||||
|
function ensureIndex(){
|
||||||
|
if (index) return Promise.resolve();
|
||||||
|
return fetch('/api/search-index').then(function(r){ return r.json(); })
|
||||||
|
.then(function(j){ index = j; }).catch(function(){ index = []; });
|
||||||
|
}
|
||||||
|
function score(q, it){
|
||||||
|
var hay = (it.title + ' ' + (it.extra || '')).toLowerCase();
|
||||||
|
if (!q) return (it.type === 'page' || it.type === 'action') ? 1 : 0.5;
|
||||||
|
if (hay.indexOf(q) === 0) return 3;
|
||||||
|
if (hay.indexOf(q) > -1) return 2;
|
||||||
|
var i = 0; for (var k = 0; k < hay.length; k++) if (hay[k] === q[i]) i++;
|
||||||
|
return i === q.length ? 1 : 0;
|
||||||
|
}
|
||||||
|
function refresh(){
|
||||||
|
var q = el('palette-input').value.trim().toLowerCase();
|
||||||
|
items = (index || []).concat(staticActions)
|
||||||
|
.map(function(it){ return { it: it, s: score(q, it) }; })
|
||||||
|
.filter(function(x){ return x.s > 0; })
|
||||||
|
.sort(function(a, b){ return b.s - a.s; })
|
||||||
|
.slice(0, 9).map(function(x){ return x.it; });
|
||||||
|
sel = 0;
|
||||||
|
el('palette-list').innerHTML = items.map(function(it, i){
|
||||||
|
var badge = it.type === 'app' ? 'app' : it.type === 'action' ? 'kommando' : it.ext ? 'link' : 'side';
|
||||||
|
return '<li class="palette-item' + (i === sel ? ' is-selected' : '') + '" data-i="' + i + '" role="option">' +
|
||||||
|
'<span>' + String(it.title).replace(/</g, '<') + '</span><span class="palette-badge">' + badge + '</span></li>';
|
||||||
|
}).join('') || '<li class="palette-item palette-empty">Ingen resultater</li>';
|
||||||
|
}
|
||||||
|
function paint(){ var ls = el('palette-list').querySelectorAll('.palette-item');
|
||||||
|
for (var i = 0; i < ls.length; i++) ls[i].classList.toggle('is-selected', i === sel); }
|
||||||
|
function choose(i){ var it = items[i]; if (!it) return; close();
|
||||||
|
if (it.run) it.run(); else if (it.ext) window.open(it.url, '_blank', 'noopener'); else window.location.href = it.url; }
|
||||||
|
function open(){ ensureIndex().then(function(){ el('palette').hidden = false; el('palette-input').value = ''; refresh(); el('palette-input').focus(); }); }
|
||||||
|
function close(){ var p = el('palette'); if (p) p.hidden = true; }
|
||||||
|
document.addEventListener('keydown', function(e){
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k'){ e.preventDefault(); el('palette').hidden ? open() : close(); return; }
|
||||||
|
var p = el('palette'); if (!p || p.hidden) return;
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
else if (e.key === 'ArrowDown'){ e.preventDefault(); sel = Math.min(sel + 1, items.length - 1); paint(); }
|
||||||
|
else if (e.key === 'ArrowUp'){ e.preventDefault(); sel = Math.max(sel - 1, 0); paint(); }
|
||||||
|
else if (e.key === 'Enter'){ e.preventDefault(); choose(sel); }
|
||||||
|
});
|
||||||
|
document.addEventListener('input', function(e){ if (e.target && e.target.id === 'palette-input') refresh(); });
|
||||||
|
document.addEventListener('click', function(e){
|
||||||
|
if (e.target === el('palette')) close();
|
||||||
|
var li = e.target.closest && e.target.closest('.palette-item[data-i]');
|
||||||
|
if (li) choose(+li.dataset.i);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Weircon — forside{% endblock %}
|
||||||
|
|
||||||
|
{% macro card(a) %}
|
||||||
|
<div class="card" data-slug="{{ a.slug }}" data-search="{{ (a.name ~ ' ' ~ a.desc ~ ' ' ~ a.host ~ ' ' ~ a.group)|lower }}">
|
||||||
|
<a class="card-link" href="{{ a.url }}" aria-label="{{ a.name }}"></a>
|
||||||
|
<button type="button" class="fav{% if a.slug in favorites %} on{% endif %}" data-slug="{{ a.slug }}"
|
||||||
|
aria-pressed="{{ 'true' if a.slug in favorites else 'false' }}" title="Markér som favorit" aria-label="Favorit">★</button>
|
||||||
|
<div class="head">
|
||||||
|
<span class="ico">{% if a.icon and a.icon.startswith('http') %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span>
|
||||||
|
<span class="name">{{ a.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sub" title="{{ a.desc if a.desc else a.host }}">{{ a.desc if a.desc else a.host }}</div>
|
||||||
|
<div class="go">Åbn →</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h1>Hej, {{ user.name }}</h1>
|
||||||
|
<p class="lead">Tjenester du har adgang til.</p>
|
||||||
|
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||||
|
{% if groups %}
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filter">
|
||||||
|
<span class="mag">⌕</span>
|
||||||
|
<input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester" />
|
||||||
|
</div>
|
||||||
|
<div class="viewtoggle" role="group" aria-label="Visning">
|
||||||
|
<button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
|
||||||
|
<button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
|
||||||
|
<h2>★ Favoritter</h2>
|
||||||
|
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="view-grouped"{% if view == 'alpha' %} hidden{% endif %}>
|
||||||
|
{% for g in groups %}
|
||||||
|
<section class="appgroup"><h2>{{ g.name }}</h2><div class="grid">{% for a in g.apps %}{{ card(a) }}{% endfor %}</div></section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div id="view-alpha"{% if view != 'alpha' %} hidden{% endif %}>
|
||||||
|
<section class="appgroup"><h2>Alle tjenester</h2><div class="grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section>
|
||||||
|
</div>
|
||||||
|
<p id="nomatch" class="muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p>
|
||||||
|
{% elif not error %}
|
||||||
|
<p class="muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<section class="appgroup">
|
||||||
|
<h2>Administration</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<a class="card" href="/admin" style="position:relative">
|
||||||
|
<div class="head"><span class="ico">⚙️</span><span class="name">Admin-panel</span></div>
|
||||||
|
<div class="sub">IP-labels, cookies, statistik, app-beskrivelser m.m.</div>
|
||||||
|
<div class="go">Åbn →</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toolbar { display:flex; gap:1rem; align-items:center; flex-wrap:wrap; margin:1.4rem 0 .2rem; }
|
||||||
|
.toolbar .filter { margin:0; flex:1; min-width:12rem; }
|
||||||
|
.viewtoggle { display:inline-flex; border:1px solid var(--border); border-radius:.7rem; overflow:hidden; }
|
||||||
|
.viewtoggle button { background:transparent; color:var(--muted); border:0; padding:.5rem .9rem; cursor:pointer; font:inherit; font-size:.88rem; }
|
||||||
|
.viewtoggle button.on { background:var(--surface2); color:var(--text); }
|
||||||
|
.card { position:relative; }
|
||||||
|
.card:hover { border-color:var(--accent); transform:translateY(-3px); background:color-mix(in srgb, var(--surface) 92%, transparent); }
|
||||||
|
.card-link { position:absolute; inset:0; z-index:1; border-radius:inherit; }
|
||||||
|
.fav { position:absolute; top:.55rem; right:.6rem; z-index:2; width:1.7rem; height:1.7rem; border-radius:.5rem;
|
||||||
|
border:0; background:transparent; color:var(--border); cursor:pointer; font-size:1.05rem; line-height:1; padding:0; transition:color .15s, transform .15s; }
|
||||||
|
.fav:hover { color:var(--muted); transform:scale(1.15); }
|
||||||
|
.fav.on { color:#f5c518; }
|
||||||
|
[hidden] { display:none !important; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
function setView(v){
|
||||||
|
var g=document.getElementById('view-grouped'), a=document.getElementById('view-alpha');
|
||||||
|
if(!g||!a) return;
|
||||||
|
g.hidden=(v==='alpha'); a.hidden=(v!=='alpha');
|
||||||
|
document.querySelectorAll('.viewtoggle button').forEach(function(b){ b.classList.toggle('on', b.getAttribute('data-view')===v); });
|
||||||
|
}
|
||||||
|
function setFav(slug,on){
|
||||||
|
document.querySelectorAll('.fav[data-slug="'+CSS.escape(slug)+'"]').forEach(function(b){
|
||||||
|
b.classList.toggle('on',on); b.setAttribute('aria-pressed', on?'true':'false');
|
||||||
|
});
|
||||||
|
var favgrid=document.getElementById('favgrid'), favsec=document.getElementById('favsec');
|
||||||
|
var has=favgrid.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
|
||||||
|
if(on && !has){
|
||||||
|
var src=document.querySelector('#view-grouped .card[data-slug="'+CSS.escape(slug)+'"]') || document.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
|
||||||
|
if(src){ var c=src.cloneNode(true); favgrid.appendChild(c); }
|
||||||
|
} else if(!on && has){ has.remove(); }
|
||||||
|
favsec.style.display = favgrid.children.length ? '' : 'none';
|
||||||
|
}
|
||||||
|
document.addEventListener('click',function(e){
|
||||||
|
var fav=e.target.closest && e.target.closest('.fav');
|
||||||
|
if(fav){ e.preventDefault(); e.stopPropagation();
|
||||||
|
var slug=fav.getAttribute('data-slug'), on=!fav.classList.contains('on');
|
||||||
|
setFav(slug,on);
|
||||||
|
fetch('/api/favorite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:slug,on:on})});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var vb=e.target.closest && e.target.closest('.viewtoggle button');
|
||||||
|
if(vb){ var v=vb.getAttribute('data-view'); setView(v);
|
||||||
|
fetch('/api/view',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({view:v})});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// filter på tværs af synlige kort
|
||||||
|
var inp=document.getElementById('appfilter');
|
||||||
|
if(inp){
|
||||||
|
inp.addEventListener('input',function(){
|
||||||
|
var q=inp.value.trim().toLowerCase(), shown=0;
|
||||||
|
document.querySelectorAll('.card[data-search]').forEach(function(c){
|
||||||
|
var hit=!q || c.getAttribute('data-search').indexOf(q)>-1;
|
||||||
|
c.classList.toggle('hidden',!hit); if(hit && c.offsetParent!==null) shown++;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.appgroup').forEach(function(s){
|
||||||
|
if(s.id==='favsec') return;
|
||||||
|
var any=s.querySelector('.card:not(.hidden)'); s.style.display=any?'':'none';
|
||||||
|
});
|
||||||
|
var nm=document.getElementById('nomatch'); if(nm) nm.style.display=shown?'none':'block';
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown',function(e){
|
||||||
|
if(e.key==='/' && document.activeElement!==inp && !/input|textarea/i.test(document.activeElement.tagName)){ e.preventDefault(); inp.focus(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Weircon — log ind{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div style="max-width:32rem;margin:10vh auto;text-align:center">
|
||||||
|
<h1 style="font-size:3rem">weircon<span style="color:var(--accent)">.</span></h1>
|
||||||
|
<p class="lead" style="margin:1rem auto 0">Selv-hostet homelab. Log ind med din konto for at se de tjenester du har adgang til.</p>
|
||||||
|
<p style="margin-top:2.2rem"><a class="btn" href="/auth/login">Log ind med Authentik →</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user