🔧 chore(repo): restructure project file hierarchy

- move project files out of fleetledger directory to root
- update .gitignore to reflect new .env path

📝 docs(README): add detailed project description

- provide an overview of FleetLedger's features and usage
- include setup instructions and security notes
This commit is contained in:
nocci 2025-12-06 11:56:16 +00:00
parent f113a760af
commit ea06f16407
27 changed files with 86 additions and 87 deletions

View file

@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold tracking-tight">Admin-Dashboard</h1>
<p class="text-xs text-slate-400">
Globale Übersicht über alle nicht archivierten Server und Benutzer.
</p>
</div>
</div>
{% if stats %}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Benutzer</span>
<span class="text-lg font-semibold text-slate-100">
{{ stats.total_users }}
</span>
<span class="text-[11px] text-slate-500 mt-1">Accounts</span>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Server</span>
<span class="text-lg font-semibold text-slate-100">
{{ stats.total_servers }}
</span>
<span class="text-[11px] text-slate-500 mt-1">nicht archiviert</span>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Laufende Kosten</span>
<span class="text-lg font-semibold text-slate-100">
{{ "%.2f"|format(stats.monthly_total) }}
{% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %}
</span>
<span class="text-[11px] text-slate-500 mt-1">
pro Monat{% if stats.mixed_currencies %} (gemischte Währungen){% endif %}
</span>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Vertragstatus</span>
<span class="text-[11px] text-amber-200">
Bald auslaufend: {{ stats.expiring_soon }}
</span>
<span class="text-[11px] text-rose-200">
Abgelaufen: {{ stats.expired }}
</span>
</div>
</div>
{% endif %}
<!-- Provider breakdown -->
<section class="space-y-2 mt-4">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Nach Provider</h2>
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/60">
<table class="min-w-full text-sm">
<thead class="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th class="px-3 py-2 text-left">Provider</th>
<th class="px-3 py-2 text-right">Server</th>
<th class="px-3 py-2 text-right">Monatskosten</th>
<th class="px-3 py-2 text-right">Laufen bald aus</th>
<th class="px-3 py-2 text-right">Abgelaufen</th>
</tr>
</thead>
<tbody>
{% if provider_stats %}
{% for provider, ps in provider_stats.items() %}
<tr class="border-t border-slate-800/80 hover:bg-slate-800/50">
<td class="px-3 py-2 text-slate-100">{{ provider }}</td>
<td class="px-3 py-2 text-right text-slate-200">{{ ps.count }}</td>
<td class="px-3 py-2 text-right text-slate-200">
{{ "%.2f"|format(ps.monthly_total) }}
{% if ps.currency_set|length == 1 %}
{{ (ps.currency_set|list)[0] }}
{% elif ps.currency_set|length > 1 %}
<span class="text-[11px] text-slate-400">(mixed)</span>
{% endif %}
</td>
<td class="px-3 py-2 text-right text-amber-200">{{ ps.expiring_soon }}</td>
<td class="px-3 py-2 text-right text-rose-200">{{ ps.expired }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="px-3 py-3 text-xs text-slate-400">
Keine Server erfasst.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</section>
<!-- Contracts: expiring soon & expired -->
<section class="grid md:grid-cols-2 gap-4 mt-4">
<div class="space-y-2">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Laufen bald aus (≤ 30 Tage)</h2>
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-3 text-xs">
{% if expiring_soon_list %}
<ul class="space-y-2">
{% for s in expiring_soon_list %}
<li class="flex items-center justify-between gap-2">
<div>
<a href="/servers/{{ s.id }}" class="text-slate-100 hover:text-indigo-300 font-medium">
{{ s.name }}
</a>
<div class="text-[11px] text-slate-400">
{{ s.provider }}
{% if s.location %} · {{ s.location }}{% endif %}
</div>
</div>
<div class="text-right text-[11px] text-amber-200">
endet {{ s.contract_end }}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-slate-500">Keine Verträge laufen in den nächsten 30 Tagen aus.</div>
{% endif %}
</div>
</div>
<div class="space-y-2">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Abgelaufene Verträge</h2>
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-3 text-xs">
{% if expired_list %}
<ul class="space-y-2">
{% for s in expired_list[:10] %}
<li class="flex items-center justify-between gap-2">
<div>
<a href="/servers/{{ s.id }}" class="text-slate-100 hover:text-indigo-300 font-medium">
{{ s.name }}
</a>
<div class="text-[11px] text-slate-400">
{{ s.provider }}
{% if s.location %} · {{ s.location }}{% endif %}
</div>
</div>
<div class="text-right text-[11px] text-rose-200">
endete {{ s.contract_end }}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-slate-500">Keine abgelaufenen Verträge gefunden.</div>
{% endif %}
</div>
</div>
</section>
</div>
{% endblock %}

129
app/templates/base.html Normal file
View file

@ -0,0 +1,129 @@
<!doctype html>
<html lang="de" class="dark" id="html-root">
<head>
<meta charset="utf-8" />
<title>FleetLedger</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#020617" />
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Tailwind via CDN for quick styling (sufficient for an MVP) -->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/style.css" />
<script>
// Apply persisted theme preference; default is dark.
(function () {
const stored = localStorage.getItem("fleetledger-theme");
const html = document.getElementById("html-root");
if (stored === "light") {
html.classList.remove("dark");
} else {
html.classList.add("dark");
}
})();
</script>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<div class="min-h-screen flex flex-col">
<header class="border-b border-slate-800 bg-slate-900/80 backdrop-blur">
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-xl bg-indigo-500/80 flex items-center justify-center text-xs font-bold">
FL
</div>
<div>
<div class="text-sm font-semibold tracking-tight">FleetLedger</div>
<div class="text-xs text-slate-400">Deine gemieteten Server im Blick</div>
</div>
</div>
<div class="flex items-center gap-3 text-xs">
{% if current_user %}
<span class="text-slate-300">
Eingeloggt als <span class="font-semibold">{{ current_user.username }}</span>
{% if current_user.is_admin %}
<span class="ml-1 px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/40 text-emerald-200 text-[10px]">Admin</span>
{% endif %}
</span>
{% if current_user.is_admin %}
<a href="/admin/dashboard" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Admin-Dashboard
</a>
<a href="/users" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
User
</a>
{% endif %}
<a href="/" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Übersicht
</a>
<a href="/map" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Karte
</a>
<a
href="/servers/new"
class="inline-flex items-center gap-1 rounded-lg bg-indigo-500 px-3 py-1.5 font-medium hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
>
<span class="text-lg leading-none"></span>
<span>Server anlegen</span>
</a>
<a
href="/logout"
class="rounded-lg border border-slate-700 px-2.5 py-1 text-slate-300 hover:border-slate-500 hover:text-white"
>
Logout
</a>
{% else %}
<a href="/login" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Login
</a>
<a href="/register" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Registrieren
</a>
{% endif %}
<button
id="theme-toggle"
class="rounded-lg border border-slate-700 bg-slate-900/60 px-2.5 py-1 text-slate-300 hover:border-slate-500 hover:text-white"
type="button"
>
🌓
</button>
</div>
</div>
</header>
<main class="flex-1">
<div class="max-w-5xl mx-auto px-4 py-6">
{% block content %}{% endblock %}
</div>
</main>
<footer class="border-t border-slate-800 bg-slate-900/60">
<div class="max-w-5xl mx-auto px-4 py-3 text-xs text-slate-500 flex justify-between items-center">
<span>Selfhosted VPS overview</span>
<span>PWA ready · Dark Mode</span>
</div>
</footer>
</div>
<script>
// Theme toggle button handler
document.getElementById("theme-toggle").addEventListener("click", () => {
const html = document.getElementById("html-root");
const isDark = html.classList.toggle("dark");
localStorage.setItem("fleetledger-theme", isDark ? "dark" : "light");
});
// Register service worker for PWA
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/service-worker.js")
.catch((err) => console.error("SW registration failed", err));
}
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

44
app/templates/login.html Normal file
View file

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-sm mx-auto mt-8">
<h1 class="text-lg font-semibold tracking-tight mb-1">Login</h1>
<p class="text-xs text-slate-400 mb-4">Melde dich an, um deine Server zu verwalten.</p>
{% if error %}
<div class="mb-4 text-xs text-rose-200 bg-rose-500/10 border border-rose-500/60 rounded-lg p-3">
{{ error }}
</div>
{% endif %}
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="space-y-1">
<label class="text-xs text-slate-300">Benutzername</label>
<input
type="text"
name="username"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Passwort</label>
<input
type="password"
name="password"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="flex justify-between items-center text-xs text-slate-400">
<span>Noch kein Account? <a href="/register" class="text-indigo-300 hover:text-indigo-200 underline">Registrieren</a></span>
</div>
<button
type="submit"
class="w-full rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
>
Einloggen
</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-sm mx-auto mt-8">
<h1 class="text-lg font-semibold tracking-tight mb-1">Registrieren</h1>
<p class="text-xs text-slate-400 mb-4">
Erstelle einen neuen Account. Der erste Benutzer wird automatisch Admin.
</p>
{% if error %}
<div class="mb-4 text-xs text-rose-200 bg-rose-500/10 border border-rose-500/60 rounded-lg p-3">
{{ error }}
</div>
{% endif %}
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="space-y-1">
<label class="text-xs text-slate-300">Benutzername</label>
<input
type="text"
name="username"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">E-Mail (optional)</label>
<input
type="email"
name="email"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Passwort</label>
<input
type="password"
name="password"
required
minlength="8"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Passwort bestätigen</label>
<input
type="password"
name="password_confirm"
required
minlength="8"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<button
type="submit"
class="w-full rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
>
Account anlegen
</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<div class="text-xs text-slate-400 mb-1">Server</div>
<h1 class="text-lg font-semibold tracking-tight flex items-center gap-2">
{{ server.name }}
{% if server.is_expired %}
<span class="inline-flex items-center rounded-full bg-rose-600/15 border border-rose-600/60 px-2 py-0.5 text-[10px] text-rose-200">
abgelaufen
</span>
{% elif server.is_expiring_soon %}
<span class="inline-flex items-center rounded-full bg-amber-500/15 border border-amber-500/60 px-2 py-0.5 text-[10px] text-amber-200">
läuft bald aus
</span>
{% endif %}
</h1>
{% if server.hostname %}
<p class="text-xs text-slate-400">{{ server.hostname }}</p>
{% endif %}
{% if server.contract_end %}
<p class="text-[11px] text-slate-500 mt-1">
Vertragsende: {{ server.contract_end }}
{% if server.days_until_contract_end is not none %}
{% if server.days_until_contract_end < 0 %}
(vor {{ (server.days_until_contract_end * -1) }} Tagen)
{% elif server.days_until_contract_end == 0 %}
(heute)
{% else %}
(in {{ server.days_until_contract_end }} Tagen)
{% endif %}
{% endif %}
</p>
{% endif %}
</div>
<div class="flex flex-wrap gap-2 text-xs items-center">
<span class="inline-flex items-center rounded-full bg-slate-800 px-2 py-0.5 text-slate-300">
{{ server.provider }}
</span>
<span class="inline-flex items-center rounded-full bg-slate-800 px-2 py-0.5 text-slate-300">
{{ server.type }}
</span>
{% if server.location %}
<span class="inline-flex items-center rounded-full bg-slate-800 px-2 py-0.5 text-slate-300">
{{ server.location }}
</span>
{% endif %}
{% if server.tags %}
<span class="inline-flex items-center rounded-full bg-indigo-500/20 border border-indigo-500/60 px-2 py-0.5 text-indigo-100">
{{ server.tags }}
</span>
{% endif %}
<a
href="/servers/{{ server.id }}/edit"
class="ml-2 rounded-lg border border-slate-700 px-3 py-1 text-xs text-slate-100 hover:border-slate-500 hover:text-white"
>
Bearbeiten
</a>
<form method="post" action="/servers/{{ server.id }}/archive" onsubmit="return confirm('Diesen Server archivieren?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button
type="submit"
class="rounded-lg border border-rose-600 px-3 py-1 text-xs text-rose-200 hover:bg-rose-600/10"
>
Archivieren
</button>
</form>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<!-- Network & Cost -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Netz & Kosten</h2>
<dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3">
<dt class="text-slate-400">IPv4</dt>
<dd class="text-right">{% if server.ipv4 %}{{ server.ipv4 }}{% else %}{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">IPv6</dt>
<dd class="text-right">{% if server.ipv6 %}{{ server.ipv6 }}{% else %}{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Kosten</dt>
<dd class="text-right">
{% if server.price %}
{{ "%.2f"|format(server.price) }} {{ server.currency }} /
{{ "Monat" if server.billing_period == "monthly" else "Jahr" }}
{% else %}{% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Vertrag</dt>
<dd class="text-right">
{% if server.contract_start %}ab {{ server.contract_start }}{% endif %}
{% if server.contract_end %} bis {{ server.contract_end }}{% endif %}
{% if not server.contract_start and not server.contract_end %}{% endif %}
</dd>
</div>
</dl>
</section>
<!-- Hardware -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Hardware</h2>
<dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3">
<dt class="text-slate-400">CPU</dt>
<dd class="text-right">
{% if server.cpu_model %}{{ server.cpu_model }}{% else %}{% endif %}
{% if server.cpu_cores %} ({{ server.cpu_cores }} Cores){% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">RAM</dt>
<dd class="text-right">
{% if server.ram_mb %}{{ server.ram_mb }} MB{% else %}{% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Storage</dt>
<dd class="text-right">
{% if server.storage_gb %}{{ server.storage_gb }} GB{% else %}{% endif %}
{% if server.storage_type %} ({{ server.storage_type }}){% endif %}
</dd>
</div>
</dl>
</section>
<!-- Access -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Zugänge</h2>
<dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Management</dt>
<dd class="text-right">
{% if server.mgmt_url %}
<a href="{{ server.mgmt_url }}" target="_blank" rel="noopener noreferrer" class="text-indigo-300 hover:text-indigo-200 underline">
Console öffnen
</a>
{% else %}{% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Mgmt-User</dt>
<dd class="text-right">{% if server.mgmt_user %}{{ server.mgmt_user }}{% else %}{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Mgmt-Passwort</dt>
<dd class="text-right">
{% if server.mgmt_password_encrypted %}
{% if mgmt_password %}
<span class="font-mono text-slate-100">{{ mgmt_password }}</span>
{% else %}
<span class="text-slate-500">verschlüsselt gespeichert (Key fehlt?)</span>
{% endif %}
{% else %}
{% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">SSH User</dt>
<dd class="text-right">{% if server.ssh_user %}{{ server.ssh_user }}{% else %}{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">SSH Key Hint</dt>
<dd class="text-right">
{% if server.ssh_key_hint %}
<span class="font-mono">{{ server.ssh_key_hint }}</span>
{% else %}{% endif %}
</dd>
</div>
</dl>
<p class="mt-2 text-[11px] text-slate-500">
Hinweis: Es werden nur Key-Namen gespeichert, keine privaten SSH-Schlüssel.
</p>
</section>
<!-- Notes -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2 md:col-span-2">
<h2 class="text-sm font-semibold text-slate-100">Notizen</h2>
<div class="text-xs text-slate-200 whitespace-pre-wrap">
{% if server.notes %}{{ server.notes }}{% else %}<span class="text-slate-500">Keine Notizen.</span>{% endif %}
</div>
</section>
</div>
<div class="flex justify-between items-center text-xs text-slate-500">
<a href="/" class="hover:text-slate-200 underline underline-offset-2">Zurück zur Übersicht</a>
<span>Zuletzt aktualisiert: {{ server.updated_at }}</span>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,315 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-3xl mx-auto">
<h1 class="text-lg font-semibold tracking-tight mb-1">
{% if server %}Server bearbeiten{% else %}Neuen Server anlegen{% endif %}
</h1>
<p class="text-xs text-slate-400 mb-4">
Trage alle relevanten Infos zu deinem VPS / Server ein.
</p>
{% if not can_encrypt %}
<div class="mb-4 text-xs text-amber-200 bg-amber-500/10 border border-amber-500/60 rounded-lg p-3">
ENCRYPTION_KEY ist nicht gesetzt eingegebene Management-Passwörter werden nicht gespeichert.
</div>
{% endif %}
<form method="post" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<!-- General -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Allgemein</h2>
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">Name *</label>
<input
type="text"
name="name"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Prod-DB-01"
value="{{ server.name if server else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Provider *</label>
<input
type="text"
name="provider"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Hetzner / OVH / ..."
value="{{ server.provider if server else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Hostname</label>
<input
type="text"
name="hostname"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="server1.example.com"
value="{{ server.hostname if server and server.hostname else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Typ</label>
<select
name="type"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
>
{% set t = server.type if server else 'vps' %}
<option value="vps" {% if t == 'vps' %}selected{% endif %}>VPS</option>
<option value="dedicated" {% if t == 'dedicated' %}selected{% endif %}>Dedicated</option>
<option value="storage" {% if t == 'storage' %}selected{% endif %}>Storage</option>
<option value="managed" {% if t == 'managed' %}selected{% endif %}>Managed</option>
<option value="other" {% if t == 'other' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Location</label>
<input
type="text"
name="location"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Falkenstein / Frankfurt / Helsinki / Ashburn"
value="{{ server.location if server and server.location else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Tags (kommagetrennt)</label>
<input
type="text"
name="tags"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="prod,critical,backup"
value="{{ server.tags if server and server.tags else '' }}"
/>
</div>
</div>
</section>
<!-- Network -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Netzwerk</h2>
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">IPv4</label>
<input
type="text"
name="ipv4"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="192.0.2.10"
value="{{ server.ipv4 if server and server.ipv4 else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">IPv6</label>
<input
type="text"
name="ipv6"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="2001:db8::10"
value="{{ server.ipv6 if server and server.ipv6 else '' }}"
/>
</div>
</div>
</section>
<!-- Costs -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Kosten</h2>
<div class="grid md:grid-cols-3 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">Betrag</label>
<input
type="number"
step="0.01"
name="price"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="5.00"
value="{{ server.price if server else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Währung</label>
<input
type="text"
name="currency"
value="{{ server.currency if server else 'EUR' }}"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Abrechnung</label>
<select
name="billing_period"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
>
{% set bp = server.billing_period if server else 'monthly' %}
<option value="monthly" {% if bp == 'monthly' %}selected{% endif %}>Monatlich</option>
<option value="yearly" {% if bp == 'yearly' %}selected{% endif %}>Jährlich</option>
<option value="other" {% if bp == 'other' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Vertragsbeginn</label>
<input
type="date"
name="contract_start"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.contract_start if server and server.contract_start else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Vertragsende</label>
<input
type="date"
name="contract_end"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.contract_end if server and server.contract_end else '' }}"
/>
</div>
</div>
</section>
<!-- Hardware -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Hardware</h2>
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">CPU-Modell</label>
<input
type="text"
name="cpu_model"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Ryzen 5 3600"
value="{{ server.cpu_model if server and server.cpu_model else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">CPU Cores</label>
<input
type="number"
name="cpu_cores"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.cpu_cores if server and server.cpu_cores else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">RAM (MB)</label>
<input
type="number"
name="ram_mb"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.ram_mb if server and server.ram_mb else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Storage (GB)</label>
<input
type="number"
name="storage_gb"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.storage_gb if server and server.storage_gb else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Storage-Typ</label>
<input
type="text"
name="storage_type"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="nvme / ssd / hdd / ceph"
value="{{ server.storage_type if server and server.storage_type else '' }}"
/>
</div>
</div>
</section>
<!-- Access -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Zugänge</h2>
<p class="text-xs text-slate-400">
SSH: hier nur <strong>Key-Namen</strong> oder Hints eintragen, keine privaten Keys.
</p>
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">Management URL</label>
<input
type="url"
name="mgmt_url"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="https://hetzner.cloud/project/..."
value="{{ server.mgmt_url if server and server.mgmt_url else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Management User</label>
<input
type="text"
name="mgmt_user"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.mgmt_user if server and server.mgmt_user else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Management Passwort</label>
<input
type="password"
name="mgmt_password"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="{% if can_encrypt %}Neues Passwort setzen (leer = unverändert){% else %}Wird NICHT gespeichert{% endif %}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">SSH User</label>
<input
type="text"
name="ssh_user"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="root / debian / nocci"
value="{{ server.ssh_user if server and server.ssh_user else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">SSH Key Hint</label>
<input
type="text"
name="ssh_key_hint"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="id_ed25519_hetzner"
value="{{ server.ssh_key_hint if server and server.ssh_key_hint else '' }}"
/>
</div>
</div>
</section>
<!-- Notes -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Notizen</h2>
<textarea
name="notes"
rows="4"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Besondere Einstellungen, Projekte, die hier laufen, etc."
>{{ server.notes if server and server.notes else '' }}</textarea>
</section>
<div class="flex justify-between items-center">
<a
href="/"
class="rounded-lg border border-slate-700 px-4 py-2 text-sm text-slate-200 hover:border-slate-500 hover:text-white"
>Abbrechen</a
>
<button
type="submit"
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
>
{% if server %}Änderungen speichern{% else %}Speichern{% endif %}
</button>
</div>
</form>
</div>
{% endblock %}
s

View file

@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold tracking-tight">Deine Server</h1>
<p class="text-xs text-slate-400">
Übersicht aller gemieteten VPS, dedizierten Server und Storage-Systeme.
</p>
</div>
<div class="flex items-center gap-3 text-xs text-slate-400">
{% if not can_encrypt %}
<span class="px-2 py-1 rounded-md border border-amber-500/60 bg-amber-500/10 text-amber-200">
Hinweis: ENCRYPTION_KEY nicht gesetzt Passwörter werden nicht gespeichert.
</span>
{% endif %}
</div>
</div>
{% if stats %}
<!-- Small dashboard summary row -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Gesamt</span>
<span class="text-lg font-semibold text-slate-100">
{{ stats.total_servers }}
</span>
<span class="text-[11px] text-slate-500 mt-1">aktive Server</span>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Laufende Kosten</span>
<span class="text-lg font-semibold text-slate-100">
{{ "%.2f"|format(stats.monthly_total) }}
{% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %}
</span>
<span class="text-[11px] text-slate-500 mt-1">
pro Monat{% if stats.mixed_currencies %} (gemischte Währungen){% endif %}
</span>
</div>
<div class="rounded-xl border border-amber-500/40 bg-amber-500/10 px-3 py-3 flex flex-col">
<span class="text-[11px] text-amber-200 mb-1">Laufen bald aus</span>
<span class="text-lg font-semibold text-amber-100">
{{ stats.expiring_soon }}
</span>
<span class="text-[11px] text-amber-200/80 mt-1">≤ 30 Tage</span>
</div>
<div class="rounded-xl border border-rose-600/60 bg-rose-600/10 px-3 py-3 flex flex-col">
<span class="text-[11px] text-rose-100 mb-1">Abgelaufen</span>
<span class="text-lg font-semibold text-rose-50">
{{ stats.expired }}
</span>
<span class="text-[11px] text-rose-100/80 mt-1">Vertrag beendet</span>
</div>
</div>
{% endif %}
{% if servers %}
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/60">
<table class="min-w-full text-sm">
<thead class="bg-slate-900/80">
<tr class="text-xs uppercase tracking-wide text-slate-400">
<th class="px-3 py-2 text-left">Name</th>
<th class="px-3 py-2 text-left">Provider</th>
<th class="px-3 py-2 text-left">Type</th>
<th class="px-3 py-2 text-left">Location</th>
<th class="px-3 py-2 text-left">IPv4</th>
<th class="px-3 py-2 text-right">Kosten</th>
</tr>
</thead>
<tbody>
{% for s in servers %}
<tr class="border-t border-slate-800/80 hover:bg-slate-800/50">
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<div>
<a href="/servers/{{ s.id }}" class="text-slate-100 hover:text-indigo-300 font-medium">
{{ s.name }}
</a>
{% if s.hostname %}
<div class="text-[11px] text-slate-400">{{ s.hostname }}</div>
{% endif %}
</div>
{% if s.is_expired %}
<span class="inline-flex items-center rounded-full bg-rose-600/15 border border-rose-600/60 px-2 py-0.5 text-[10px] text-rose-200">
abgelaufen
</span>
{% elif s.is_expiring_soon %}
<span class="inline-flex items-center rounded-full bg-amber-500/15 border border-amber-500/60 px-2 py-0.5 text-[10px] text-amber-200">
läuft bald aus
</span>
{% endif %}
</div>
</td>
<td class="px-3 py-2 text-slate-200">{{ s.provider }}</td>
<td class="px-3 py-2">
<span class="inline-flex rounded-full bg-slate-800 px-2 py-0.5 text-[11px] text-slate-300">
{{ s.type }}
</span>
</td>
<td class="px-3 py-2 text-slate-200">
{% if s.location %}{{ s.location }}{% else %}<span class="text-slate-500"></span>{% endif %}
</td>
<td class="px-3 py-2 text-slate-200">
{% if s.ipv4 %}{{ s.ipv4 }}{% else %}<span class="text-slate-500"></span>{% endif %}
</td>
<td class="px-3 py-2 text-right text-slate-200">
{% if s.price %}
{{ "%.2f"|format(s.price) }} {{ s.currency }}
<span class="text-[11px] text-slate-400">/ {{ "Monat" if s.billing_period == "monthly" else "Jahr" }}</span>
{% else %}
<span class="text-slate-500"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="rounded-xl border border-dashed border-slate-700 bg-slate-900/40 p-6 text-sm text-slate-300">
Noch keine Server erfasst. Leg den ersten mit „Server anlegen“ oben rechts an.
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block extra_head %}
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<style>
/* Make Leaflet popups look a bit nicer with Tailwind-ish typography */
.leaflet-popup-content-wrapper {
background-color: #020617;
color: #e5e7eb;
border-radius: 0.75rem;
border: 1px solid #1f2937;
}
.leaflet-popup-tip {
background-color: #020617;
}
</style>
{% endblock %}
{% block content %}
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold tracking-tight">Server-Karte</h1>
<p class="text-xs text-slate-400">
Zeigt alle nicht archivierten Server mit gesetzter Location auf einer Karte.
Für grobe Übersicht reicht die Stadt keine exakten GPS-Daten notwendig.
</p>
</div>
</div>
<!-- Hidden list of servers passed via data attributes to JS -->
<ul id="server-data" class="hidden">
{% for s in servers %}
<li
data-id="{{ s.id }}"
data-name="{{ s.name }}"
data-provider="{{ s.provider }}"
data-ipv4="{{ s.ipv4 or '' }}"
data-location="{{ s.location or '' }}"
></li>
{% endfor %}
</ul>
<div
id="map"
class="w-full h-[520px] rounded-xl border border-slate-800 bg-slate-900/70"
></div>
<p class="text-[11px] text-slate-500">
Hinweis: Marker werden anhand der Location-Namen grob auf der Weltkarte platziert. Mehrere Server
in derselben Stadt werden leicht versetzt dargestellt, damit sie klickbar bleiben.
</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Basic HTML escaping to avoid XSS when injecting text into popups
function escapeHtml(str) {
return String(str || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Very small built-in city coordinate map (approximate, not exhaustive).
// Keys are lowercase.
const CITY_COORDS = {
"berlin": [52.52, 13.405],
"frankfurt": [50.1109, 8.6821],
"nuremberg": [49.4521, 11.0767],
"nürnberg": [49.4521, 11.0767],
"falkenstein": [50.474, 12.371],
"helsinki": [60.1699, 24.9384],
"amsterdam": [52.3676, 4.9041],
"paris": [48.8566, 2.3522],
"london": [51.5074, -0.1278],
"vienna": [48.2082, 16.3738],
"wien": [48.2082, 16.3738],
"zurich": [47.3769, 8.5417],
"zürich": [47.3769, 8.5417],
"stockholm": [59.3293, 18.0686],
"prague": [50.0755, 14.4378],
"praha": [50.0755, 14.4378],
"warsaw": [52.2297, 21.0122],
"madrid": [40.4168, -3.7038],
"rome": [41.9028, 12.4964],
"milano": [45.4642, 9.19],
"new york": [40.7128, -74.006],
"nyc": [40.7128, -74.006],
"chicago": [41.8781, -87.6298],
"ireland": [53.3498, -6.2603], // Dublin
"irland": [53.3498, -6.2603],
"dublin": [53.3498, -6.2603],
"romania": [44.4268, 26.1025], // Bucharest
"rumänien": [44.4268, 26.1025],
"bucharest": [44.4268, 26.1025],
"bucuresti": [44.4268, 26.1025],
"bucurești": [44.4268, 26.1025],
"moldova": [47.0105, 28.8638], // Chișinău
"moldau": [47.0105, 28.8638],
"chisinau": [47.0105, 28.8638],
"chișinău": [47.0105, 28.8638],
"ashburn": [39.0438, -77.4874]
};
// Deterministic string hash → used for pseudo coordinates for unknown locations
function hashString(str) {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h << 5) - h + str.charCodeAt(i);
h |= 0; // force 32-bit
}
return h;
}
function getBaseCoords(locationKey) {
const key = locationKey.toLowerCase();
if (Object.prototype.hasOwnProperty.call(CITY_COORDS, key)) {
return CITY_COORDS[key];
}
// Fuzzy match: if the city keyword appears in the location string
for (const cityKey in CITY_COORDS) {
if (key.includes(cityKey)) {
return CITY_COORDS[cityKey];
}
}
return null;
}
document.addEventListener("DOMContentLoaded", () => {
const list = document.querySelectorAll("#server-data li");
const servers = Array.from(list).map((el) => ({
id: parseInt(el.dataset.id, 10),
name: el.dataset.name || "",
provider: el.dataset.provider || "",
ipv4: el.dataset.ipv4 || "",
location: el.dataset.location || ""
}));
const map = L.map("map", {
worldCopyJump: true,
scrollWheelZoom: true,
zoomSnap: 0.25
}).setView([20, 0], 2);
// Simple OpenStreetMap tiles
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
}).addTo(map);
const markers = [];
const locationCounts = {};
// Create markers; approximate coordinates based on location string
for (const s of servers) {
const rawLoc = (s.location || "").trim();
if (!rawLoc) {
// no location → skip from map
continue;
}
const key = rawLoc.toLowerCase();
// Base coordinates: either from CITY_COORDS or a deterministic fallback
let base = getBaseCoords(key);
if (!base) {
const h = hashString(key);
// Roughly distribute unknown locations across -60..+60 lat, -180..+180 lon
const lat = ((h % 12000) / 100) - 60; // -60 .. +60
const lng = ((((h / 12000) | 0) % 36000) / 100) - 180; // -180 .. +180
base = [lat, lng];
}
const count = locationCounts[key] || 0;
locationCounts[key] = count + 1;
// Slight radial offset for multiple servers in the same city
const offset = 0.15; // degrees (~1520km)
const angle = (count * 2 * Math.PI) / 6; // hex-like distribution
const lat = base[0] + offset * Math.sin(angle);
const lng = base[1] + offset * Math.cos(angle);
const popupContent = `
<div class="text-sm font-medium">${escapeHtml(s.name)}</div>
<div class="text-xs text-slate-400">${escapeHtml(s.provider)}</div>
${
s.ipv4
? `<div class="text-xs mt-1 font-mono text-slate-300">${escapeHtml(s.ipv4)}</div>`
: ""
}
${
rawLoc
? `<div class="text-[11px] text-slate-500 mt-1">${escapeHtml(rawLoc)}</div>`
: ""
}
<a href="/servers/${s.id}" class="text-indigo-400 underline text-xs mt-2 block">Details</a>
`;
const marker = L.circleMarker([lat, lng], {
radius: 6,
fillColor: "#6366f1",
color: "#1e3a8a",
weight: 1,
opacity: 0.8,
fillOpacity: 0.9
}).bindPopup(popupContent);
marker.addTo(map);
markers.push(marker);
}
// Auto-fit map to markers if we have any
if (markers.length > 0) {
const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.25));
} else {
// No markers → keep default world view
map.setView([20, 0], 2);
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold tracking-tight">Benutzerverwaltung</h1>
<p class="text-xs text-slate-400">
Admins können Benutzer aktivieren/deaktivieren. Der eigene Account kann nicht deaktiviert werden.
</p>
</div>
</div>
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/60">
<table class="min-w-full text-sm">
<thead class="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th class="px-3 py-2 text-left">Benutzername</th>
<th class="px-3 py-2 text-left">E-Mail</th>
<th class="px-3 py-2 text-left">Rolle</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr class="border-t border-slate-800/80 hover:bg-slate-800/50">
<td class="px-3 py-2">
<span class="font-medium text-slate-100">{{ u.username }}</span>
</td>
<td class="px-3 py-2 text-slate-200">
{% if u.email %}{{ u.email }}{% else %}<span class="text-slate-500"></span>{% endif %}
</td>
<td class="px-3 py-2 text-slate-200">
{% if u.is_admin %}Admin{% else %}User{% endif %}
</td>
<td class="px-3 py-2 text-slate-200">
{% if u.is_active %}
<span class="inline-flex rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] text-emerald-200 border border-emerald-500/50">
aktiv
</span>
{% else %}
<span class="inline-flex rounded-full bg-slate-800 px-2 py-0.5 text-[11px] text-slate-300 border border-slate-600">
deaktiviert
</span>
{% endif %}
</td>
<td class="px-3 py-2 text-right">
{% if u.id != current_user.id %}
<form method="post" action="/users/{{ u.id }}/toggle-active">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button
type="submit"
class="rounded-lg border border-slate-700 px-3 py-1 text-xs text-slate-200 hover:border-slate-500 hover:text-white"
>
{% if u.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
{% else %}
<span class="text-[11px] text-slate-500">Eigener Account</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}