CardSync/frontend/admin.html

1257 lines
45 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CardSync — Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0b0f;
--sidebar: #0e0f14;
--surface: #14161d;
--surface2: #1a1c25;
--surface3: #20232e;
--border: #252833;
--accent: #4f8ef7;
--accent2: #7c5cfc;
--success: #3ecf8e;
--error: #f76c6c;
--warning: #ffb900;
--text: #e4e6ef;
--muted: #6b7090;
--muted2: #4a4f66;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
display: flex;
}
/* Sidebar */
.sidebar {
width: 240px;
background: var(--sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
}
.sidebar-logo {
padding: 24px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 32px; height: 32px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.logo-text {
font-family: 'DM Serif Display', serif;
font-size: 18px;
letter-spacing: -0.3px;
}
.logo-badge {
font-size: 9px;
background: rgba(79,142,247,0.15);
color: var(--accent);
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-left: auto;
}
.sidebar-nav {
flex: 1;
padding: 16px 0;
}
.nav-section {
padding: 0 12px;
margin-bottom: 4px;
}
.nav-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--muted2);
padding: 8px 8px 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
color: var(--muted);
font-size: 14px;
transition: all 0.15s;
border: none;
background: none;
width: 100%;
text-align: left;
font-family: 'DM Sans', sans-serif;
}
.nav-item:hover { background: var(--surface2); color: var(--text); }
.nav-item.active { background: rgba(79,142,247,0.12); color: var(--accent); }
.nav-item .icon { font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; }
.sidebar-footer {
padding: 16px 12px;
border-top: 1px solid var(--border);
}
.admin-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--surface2);
border-radius: 8px;
margin-bottom: 8px;
}
.admin-avatar {
width: 28px; height: 28px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: white;
}
.admin-info { flex: 1; min-width: 0; }
.admin-email { font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.admin-role { font-size: 10px; color: var(--accent); font-weight: 500; }
.btn-logout {
width: 100%;
padding: 8px;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
font-family: 'DM Sans', sans-serif;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.btn-logout:hover { border-color: var(--error); color: var(--error); }
/* Main Content */
.main {
flex: 1;
margin-left: 240px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.topbar {
padding: 16px 32px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg);
position: sticky;
top: 0;
z-index: 50;
}
.topbar-title {
font-family: 'DM Serif Display', serif;
font-size: 20px;
letter-spacing: -0.3px;
}
.topbar-right {
display: flex;
gap: 8px;
align-items: center;
}
/* Content area */
.content {
flex: 1;
padding: 32px;
}
/* Panels (tab views) */
.panel { display: none; }
.panel.active { display: block; }
/* Stats grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
opacity: 0.6;
}
.stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); margin-bottom: 8px; font-weight: 500; }
.stat-value { font-family: 'DM Serif Display', serif; font-size: 32px; line-height: 1; }
.stat-sub { font-size: 12px; color: var(--muted); margin-top: 6px; }
/* Card */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 24px;
overflow: hidden;
}
.card-header {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title { font-size: 15px; font-weight: 600; }
.card-subtitle { font-size: 12px; color: var(--muted); margin-top: 2px; }
.card-body { padding: 24px; }
/* Form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { margin-bottom: 16px; }
.form-group.full { grid-column: 1 / -1; }
.form-label { display: block; font-size: 12px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.input, .select {
width: 100%;
padding: 10px 14px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.input:focus, .select:focus { border-color: var(--accent); }
.input[type="password"] { letter-spacing: 2px; }
.select option { background: var(--surface2); }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: 8px;
font-family: 'DM Sans', sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: white; }
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover:not(:disabled) { border-color: var(--accent); }
.btn-success { background: rgba(62,207,142,0.15); color: var(--success); border: 1px solid rgba(62,207,142,0.25); }
.btn-success:hover:not(:disabled) { background: rgba(62,207,142,0.25); }
.btn-danger { background: rgba(247,108,108,0.12); color: var(--error); border: 1px solid rgba(247,108,108,0.2); }
.btn-danger:hover:not(:disabled) { background: rgba(247,108,108,0.2); }
.btn-warning { background: rgba(255,185,0,0.12); color: var(--warning); border: 1px solid rgba(255,185,0,0.2); }
.btn-warning:hover:not(:disabled) { background: rgba(255,185,0,0.2); }
.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 6px; }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
/* Alert */
.alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
display: none;
}
.alert.success { background: rgba(62,207,142,0.1); color: var(--success); border: 1px solid rgba(62,207,142,0.2); display: flex; }
.alert.error { background: rgba(247,108,108,0.1); color: var(--error); border: 1px solid rgba(247,108,108,0.2); display: flex; }
.alert.info { background: rgba(79,142,247,0.1); color: var(--accent); border: 1px solid rgba(79,142,247,0.2); display: flex; }
/* Table */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 10px 16px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
font-weight: 500;
border-bottom: 1px solid var(--border);
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
font-size: 13px;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
.badge-status {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 100px;
font-size: 11px;
font-weight: 500;
}
.badge-status.success { background: rgba(62,207,142,0.12); color: var(--success); }
.badge-status.error { background: rgba(247,108,108,0.12); color: var(--error); }
.badge-status.running { background: rgba(255,185,0,0.12); color: var(--warning); }
.badge-status.off { background: rgba(255,255,255,0.05); color: var(--muted); }
.dot-indicator { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.mono { font-family: 'DM Mono', monospace; font-size: 12px; color: var(--muted); }
/* Loading */
.loading {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
padding: 20px 0;
font-size: 13px;
}
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* User detail modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
z-index: 500;
display: none;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
width: 100%;
max-width: 560px;
max-height: 90vh;
overflow-y: auto;
padding: 28px;
}
.modal-title { font-family: 'DM Serif Display', serif; font-size: 20px; margin-bottom: 4px; }
.modal-sub { font-size: 13px; color: var(--muted); margin-bottom: 24px; }
.modal-close {
position: absolute;
top: 16px; right: 16px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-family: 'DM Sans', sans-serif;
}
/* Log entries */
.log-entry {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
margin-bottom: 8px;
background: var(--surface2);
font-size: 12px;
}
.log-entry.success { border-left: 3px solid var(--success); }
.log-entry.error { border-left: 3px solid var(--error); }
.log-meta { display: flex; justify-content: space-between; margin-bottom: 4px; }
.log-time { color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; }
.log-stats { color: var(--muted); font-size: 11px; margin-top: 4px; }
.log-error { color: var(--error); font-size: 11px; margin-top: 4px; font-family: 'DM Mono', monospace; }
/* Access Denied */
#access-denied { display: none; }
</style>
</head>
<body>
<!-- ACCESS DENIED -->
<div id="access-denied" style="flex:1;display:none;align-items:center;justify-content:center;flex-direction:column;gap:16px;min-height:100vh;text-align:center">
<div style="font-size:48px">🚫</div>
<h2 style="font-family:'DM Serif Display',serif;font-size:28px">Kein Zugriff</h2>
<p style="color:var(--muted);max-width:360px">Du bist nicht als Admin registriert. Bitte wende dich an den Systemadministrator.</p>
<a href="/" style="color:var(--accent);text-decoration:none;font-size:14px">← Zurück zur Startseite</a>
</div>
<!-- MAIN APP -->
<div id="app" style="display:none;flex:1;display:none">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-logo">
<div class="logo-icon"></div>
<span class="logo-text">CardSync</span>
<span class="logo-badge">Admin</span>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-label">Übersicht</div>
<button class="nav-item active" onclick="showPanel('dashboard', this)">
<span class="icon">📊</span> Dashboard
</button>
</div>
<div class="nav-section">
<div class="nav-label">Verwaltung</div>
<button class="nav-item" onclick="showPanel('users', this)">
<span class="icon">👥</span> Benutzer
</button>
<button class="nav-item" onclick="showPanel('azure-group', this)">
<span class="icon">🏢</span> Azure AD Gruppe
</button>
<button class="nav-item" onclick="showPanel('config', this)">
<span class="icon">⚙️</span> Synology Config
</button>
</div>
</nav>
<div class="sidebar-footer">
<div class="admin-badge">
<div class="admin-avatar" id="admin-avatar">A</div>
<div class="admin-info">
<div class="admin-email" id="admin-email"></div>
<div class="admin-role">Administrator</div>
</div>
</div>
<button class="btn-logout" onclick="logout()">Abmelden</button>
</div>
</aside>
<!-- Main -->
<div class="main">
<div class="topbar">
<div class="topbar-title" id="topbar-title">Dashboard</div>
<div class="topbar-right">
<span id="topbar-status" style="font-size:12px;color:var(--muted)">Laden…</span>
</div>
</div>
<div class="content">
<!-- Dashboard Panel -->
<div id="panel-dashboard" class="panel active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Benutzer gesamt</div>
<div class="stat-value" id="stat-total"></div>
<div class="stat-sub">registriert</div>
</div>
<div class="stat-card">
<div class="stat-label">Sync aktiv</div>
<div class="stat-value" id="stat-active"></div>
<div class="stat-sub">Benutzer mit aktivem Sync</div>
</div>
<div class="stat-card">
<div class="stat-label">Letzte Syncs</div>
<div class="stat-value" id="stat-recent"></div>
<div class="stat-sub">in den letzten 5 Einträgen</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div>
<div class="card-title">Letzte Sync-Aktivitäten</div>
<div class="card-subtitle">Die 5 zuletzt gestarteten Sync-Vorgänge</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="loadDashboard()">↺ Aktualisieren</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Benutzer</th>
<th>Zeitpunkt</th>
<th>Status</th>
<th>Kontakte</th>
</tr>
</thead>
<tbody id="recent-logs-tbody">
<tr><td colspan="4" style="color:var(--muted);text-align:center;padding:20px">Lade…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Users Panel -->
<div id="panel-users" class="panel">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">Benutzer</div>
<div class="card-subtitle">Alle registrierten Benutzer und ihr Sync-Status</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="loadUsers()">↺ Aktualisieren</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>E-Mail</th>
<th>Sync</th>
<th>Adressbuch</th>
<th>Intervall</th>
<th>Letzter Sync</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="users-tbody">
<tr><td colspan="6" style="color:var(--muted);text-align:center;padding:20px">Lade…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Azure AD Group Panel -->
<div id="panel-azure-group" class="panel">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">Azure AD Gruppen-Integration</div>
<div class="card-subtitle">Importiere User automatisch aus einer Azure AD Gruppe — kein OAuth-Login durch User nötig</div>
</div>
</div>
<div class="card-body">
<div id="azure-alert" class="alert"></div>
<div class="form-grid">
<div class="form-group full">
<label class="form-label">Azure AD Gruppen-ID (Object ID)</label>
<input type="text" id="az-group-id" class="input" placeholder="z.B. a1b2c3d4-e5f6-7890-abcd-ef1234567890" style="font-family:'DM Mono',monospace">
<div style="font-size:11px;color:var(--muted);margin-top:4px">In Azure Portal → Azure AD → Gruppen → deine Gruppe → "Object ID" kopieren</div>
</div>
<div class="form-group">
<label class="form-label">Mitglieder-Abgleich</label>
<select id="az-auto-sync" class="select">
<option value="true">Automatisch (empfohlen)</option>
<option value="false">Nur manuell</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Abgleich-Intervall</label>
<select id="az-sync-interval" class="select">
<option value="1">Stündlich</option>
<option value="6" selected>Alle 6 Stunden</option>
<option value="12">Alle 12 Stunden</option>
<option value="24">Täglich</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Default-Adressbuch für neue User</label>
<select id="az-default-folder" class="select">
<option value="">— Adressbuch wählen —</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Default Sync-Intervall</label>
<select id="az-default-interval" class="select">
<option value="15">Alle 15 Minuten</option>
<option value="30">Alle 30 Minuten</option>
<option value="60" selected>Stündlich</option>
<option value="360">Alle 6 Stunden</option>
<option value="720">Alle 12 Stunden</option>
<option value="1440">Täglich</option>
</select>
</div>
<div class="form-group full">
<label class="form-label">Neue User direkt aktiviert?</label>
<select id="az-default-enabled" class="select">
<option value="true">Ja — Sync läuft sofort nach Import</option>
<option value="false">Nein — Admin muss jeden User manuell aktivieren</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="saveAzureGroup()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="testAzureGroup()">🔌 Gruppe testen</button>
<button class="btn btn-success" onclick="syncGroupNow()" id="az-sync-btn">⚡ Mitglieder jetzt importieren</button>
<button class="btn btn-danger" onclick="deleteAzureGroup()">🗑 Gruppen-Konfig entfernen</button>
</div>
<div id="az-status" style="margin-top:20px;padding:14px 16px;background:var(--surface2);border-radius:8px;font-size:13px;display:none">
<div style="font-weight:500;margin-bottom:6px" id="az-status-title">Status</div>
<div style="color:var(--muted)" id="az-status-message"></div>
<div class="mono" style="font-size:11px;color:var(--muted2);margin-top:6px" id="az-status-time"></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Wichtig: Azure App Permissions</div>
<div class="card-subtitle">Für Gruppen-Import sind Application Permissions nötig (nicht nur Delegated)</div>
</div>
<div class="card-body" style="color:var(--muted);font-size:13px;line-height:1.8">
In Azure Portal → App-Registrierungen → CardSync → <strong style="color:var(--text)">API-Berechtigungen</strong><strong style="color:var(--text)">Berechtigung hinzufügen</strong> → Microsoft Graph → <strong style="color:var(--text)">Anwendungsberechtigungen</strong>:
<ul style="padding-left:20px;margin-top:8px;display:flex;flex-direction:column;gap:4px">
<li><span class="mono">User.Read.All</span> — User-Daten lesen</li>
<li><span class="mono">GroupMember.Read.All</span> — Gruppenmitglieder lesen</li>
<li><span class="mono">Contacts.ReadWrite</span> (Application!) — Kontakte aller User schreiben</li>
</ul>
<div style="margin-top:14px;padding:10px 14px;background:rgba(255,185,0,0.1);border:1px solid rgba(255,185,0,0.2);border-radius:8px;color:var(--warning);font-size:12px">
⚠️ Nach dem Hinzufügen <strong>"Administratorzustimmung erteilen"</strong> klicken — sonst funktioniert es nicht.
</div>
</div>
</div>
</div>
<!-- Config Panel -->
<div id="panel-config" class="panel">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">Synology CardDAV Konfiguration</div>
<div class="card-subtitle">Zentrale Servereinstellungen für alle Benutzer</div>
</div>
</div>
<div class="card-body">
<div id="config-alert" class="alert"></div>
<div class="form-grid">
<div class="form-group full">
<label class="form-label">Server URL</label>
<input type="url" id="cfg-url" class="input" placeholder="https://192.168.1.10:5006">
<div style="font-size:11px;color:var(--muted);margin-top:4px">Synology-IP oder Hostname mit Port (Standard: 5006 für CardDAV)</div>
</div>
<div class="form-group">
<label class="form-label">Benutzername</label>
<input type="text" id="cfg-user" class="input" placeholder="admin">
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<input type="password" id="cfg-pass" class="input" placeholder="••••••••">
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="saveConfig()">💾 Speichern</button>
<button class="btn btn-secondary" onclick="testConfig()">🔌 Verbindung testen</button>
<button class="btn btn-secondary" onclick="clearCache()" title="Erzwingt frische CardDAV-Daten beim nächsten Sync">🔄 Cache leeren</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Azure App Registration</div>
<div class="card-subtitle">Einrichtungsanleitung für Microsoft OAuth</div>
</div>
<div class="card-body" style="color:var(--muted);font-size:13px;line-height:1.8">
<ol style="padding-left:20px;display:flex;flex-direction:column;gap:8px">
<li>Gehe zu <a href="https://portal.azure.com" target="_blank" style="color:var(--accent)">portal.azure.com</a> → Azure Active Directory → App-Registrierungen</li>
<li>Klicke auf <strong style="color:var(--text)">Neue Registrierung</strong></li>
<li>Name: <span class="mono">CardSync</span> | Kontotypen: <strong style="color:var(--text)">Nur eigene Organisation</strong> oder Multitenant je nach Bedarf</li>
<li>Redirect URI (Web): <span class="mono" id="redirect-uri" style="color:var(--accent)">http://deine-domain/auth/callback</span></li>
<li>Unter <strong style="color:var(--text)">Zertifikate & Geheimnisse</strong> ein neues Client-Secret erstellen</li>
<li>Unter <strong style="color:var(--text)">API-Berechtigungen</strong> hinzufügen: <span class="mono">Contacts.ReadWrite</span>, <span class="mono">offline_access</span>, <span class="mono">openid</span>, <span class="mono">profile</span>, <span class="mono">email</span></li>
<li>Client-ID, Secret und Tenant-ID in die <span class="mono">.env</span> Datei eintragen</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- User Edit Modal -->
<div class="modal-overlay" id="user-modal">
<div class="modal" style="position:relative">
<button class="modal-close" onclick="closeModal()">✕ Schließen</button>
<div class="modal-title" id="modal-user-name">Benutzer</div>
<div class="modal-sub" id="modal-user-email"></div>
<div class="form-group">
<label class="form-label">Adressbuch (CardDAV-Ordner URL)</label>
<select id="modal-folder" class="select">
<option value="">Wird geladen…</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Sync-Intervall</label>
<select id="modal-interval" class="select">
<option value="15">Alle 15 Minuten</option>
<option value="30">Alle 30 Minuten</option>
<option value="60">Stündlich</option>
<option value="360">Alle 6 Stunden</option>
<option value="720">Alle 12 Stunden</option>
<option value="1440">Täglich</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Sync aktiviert</label>
<select id="modal-sync-enabled" class="select">
<option value="true">Ja — Sync aktiv</option>
<option value="false">Nein — Sync pausiert</option>
</select>
</div>
<div class="btn-row" style="margin-bottom:24px">
<button class="btn btn-primary" onclick="saveUserModal()">💾 Speichern</button>
<button class="btn btn-warning" onclick="syncUserNow()" id="modal-sync-btn">⚡ Jetzt synchronisieren</button>
<button class="btn btn-danger" onclick="deleteUser()">🗑 Löschen</button>
</div>
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px">Sync-Verlauf</div>
<div id="modal-logs">
<div class="loading"><div class="spinner"></div> Lade Logs…</div>
</div>
</div>
</div>
<script>
const API = '';
let currentUserId = null;
let usersCache = [];
let folderCache = [];
async function init() {
try {
const resp = await fetch(`${API}/auth/me`, { credentials: 'include' });
if (!resp.ok) throw new Error('not logged in');
const me = await resp.json();
if (!me.is_admin) {
document.getElementById('access-denied').style.display = 'flex';
document.getElementById('app').style.display = 'none';
return;
}
document.getElementById('access-denied').style.display = 'none';
document.getElementById('app').style.display = 'flex';
document.getElementById('admin-email').textContent = me.email;
document.getElementById('admin-avatar').textContent = me.email[0].toUpperCase();
// Redirect URI anzeigen
document.getElementById('redirect-uri').textContent = `${window.location.origin}/auth/callback`;
await Promise.all([loadDashboard(), loadConfig(), loadFolderCache()]);
} catch (e) {
window.location.href = '/auth/login?mode=admin';
}
}
function showPanel(name, el) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
document.getElementById(`panel-${name}`).classList.add('active');
el.classList.add('active');
const titles = { dashboard: 'Dashboard', users: 'Benutzer', 'azure-group': 'Azure AD Gruppe', config: 'Synology Config' };
document.getElementById('topbar-title').textContent = titles[name] || name;
if (name === 'users') loadUsers();
if (name === 'azure-group') loadAzureGroup();
}
async function loadDashboard() {
try {
const [statsResp] = await Promise.all([
fetch(`${API}/api/admin/stats`, { credentials: 'include' }),
]);
const stats = await statsResp.json();
document.getElementById('stat-total').textContent = stats.total_users;
document.getElementById('stat-active').textContent = stats.sync_enabled_users;
document.getElementById('stat-recent').textContent = stats.recent_logs.length;
document.getElementById('topbar-status').textContent = `${stats.sync_enabled_users}/${stats.total_users} Sync aktiv`;
// User-Map für Logs laden
const usersResp = await fetch(`${API}/api/admin/users`, { credentials: 'include' });
const usersData = await usersResp.json();
usersCache = usersData.users;
const tbody = document.getElementById('recent-logs-tbody');
if (!stats.recent_logs.length) {
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--muted);text-align:center;padding:20px">Noch keine Sync-Vorgänge</td></tr>';
return;
}
tbody.innerHTML = stats.recent_logs.map(l => {
const user = usersCache.find(u => u.id === l.user_id);
return `<tr>
<td>${user ? user.email : `User #${l.user_id}`}</td>
<td class="mono">${l.started_at ? new Date(l.started_at).toLocaleString('de-DE') : '—'}</td>
<td><span class="badge-status ${l.status || 'off'}"><span class="dot-indicator"></span>${l.status || '—'}</span></td>
<td>${l.contacts_synced ?? '—'}</td>
</tr>`;
}).join('');
} catch (e) {
console.error('Dashboard load error:', e);
}
}
async function loadUsers() {
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--muted);text-align:center;padding:20px"><div class="loading" style="justify-content:center"><div class="spinner"></div> Lade…</div></td></tr>';
try {
const resp = await fetch(`${API}/api/admin/users`, { credentials: 'include' });
const data = await resp.json();
usersCache = data.users;
if (!data.users.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--muted);text-align:center;padding:24px">Noch keine Benutzer registriert</td></tr>';
return;
}
tbody.innerHTML = data.users.map(u => {
const folderName = u.carddav_folder ? u.carddav_folder.split('/').filter(Boolean).pop() || u.carddav_folder : '—';
const lastSync = u.last_sync_at ? new Date(u.last_sync_at).toLocaleString('de-DE') : '—';
const statusClass = u.sync_enabled ? (u.last_sync_status || 'off') : 'off';
const statusLabel = u.sync_enabled ? (u.last_sync_status || 'Noch kein Sync') : 'Deaktiviert';
const sourceBadge = u.source === 'group_import'
? '<span class="badge-status" style="background:rgba(124,92,252,0.12);color:#7c5cfc;font-size:9px;padding:2px 6px;margin-left:6px">AD</span>'
: '<span class="badge-status" style="background:rgba(79,142,247,0.12);color:#4f8ef7;font-size:9px;padding:2px 6px;margin-left:6px">OAuth</span>';
return `<tr>
<td>
<div style="font-weight:500">${u.display_name || u.email}${sourceBadge}</div>
<div class="mono">${u.email}</div>
</td>
<td><span class="badge-status ${u.sync_enabled ? 'success' : 'off'}"><span class="dot-indicator"></span>${u.sync_enabled ? 'Aktiv' : 'Aus'}</span></td>
<td class="mono" title="${u.carddav_folder || ''}">${folderName}</td>
<td>${u.sync_interval_minutes ? `${u.sync_interval_minutes} Min.` : '—'}</td>
<td>
<div class="mono" style="font-size:11px">${lastSync}</div>
<span class="badge-status ${statusClass}" style="margin-top:4px"><span class="dot-indicator"></span>${statusLabel}</span>
</td>
<td>
<button class="btn btn-secondary btn-sm" onclick="openUserModal(${u.id})">✏️ Bearbeiten</button>
</td>
</tr>`;
}).join('');
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--error);text-align:center;padding:20px">Fehler beim Laden</td></tr>';
}
}
async function loadFolderCache() {
try {
const resp = await fetch(`${API}/api/carddav/folders`, { credentials: 'include' });
if (resp.ok) {
const data = await resp.json();
folderCache = data.folders || [];
}
} catch {}
}
async function openUserModal(userId) {
currentUserId = userId;
const user = usersCache.find(u => u.id === userId);
if (!user) return;
document.getElementById('modal-user-name').textContent = user.display_name || user.email;
document.getElementById('modal-user-email').textContent = user.email;
document.getElementById('modal-sync-enabled').value = String(user.sync_enabled);
document.getElementById('modal-interval').value = user.sync_interval_minutes || 60;
// Folder Dropdown befüllen
const sel = document.getElementById('modal-folder');
sel.innerHTML = '<option value="">Kein Adressbuch ausgewählt</option>';
folderCache.forEach(f => {
const opt = document.createElement('option');
opt.value = f.url;
opt.textContent = f.name;
if (f.url === user.carddav_folder) opt.selected = true;
sel.appendChild(opt);
});
// Logs laden
document.getElementById('modal-logs').innerHTML = '<div class="loading"><div class="spinner"></div> Lade Logs…</div>';
document.getElementById('user-modal').classList.add('open');
try {
const resp = await fetch(`${API}/api/admin/users/${userId}/logs`, { credentials: 'include' });
const data = await resp.json();
renderModalLogs(data.logs);
} catch {
document.getElementById('modal-logs').innerHTML = '<div style="color:var(--error);font-size:12px">Fehler beim Laden der Logs</div>';
}
}
function renderModalLogs(logs) {
const container = document.getElementById('modal-logs');
if (!logs.length) {
container.innerHTML = '<div style="color:var(--muted);font-size:12px">Noch keine Sync-Einträge</div>';
return;
}
container.innerHTML = logs.map(l => `
<div class="log-entry ${l.status || ''}">
<div class="log-meta">
<span class="badge-status ${l.status}"><span class="dot-indicator"></span>${l.status}</span>
<span class="log-time">${l.started_at ? new Date(l.started_at).toLocaleString('de-DE') : '—'}</span>
</div>
${l.status === 'success' ? `<div class="log-stats">+${l.contacts_created} erstellt · ~${l.contacts_updated} aktualisiert · ${l.contacts_deleted} gelöscht · ${l.contacts_synced} gesamt</div>` : ''}
${l.error_message ? `<div class="log-error">${l.error_message}</div>` : ''}
</div>
`).join('');
}
function closeModal() {
document.getElementById('user-modal').classList.remove('open');
currentUserId = null;
}
async function saveUserModal() {
if (!currentUserId) return;
const payload = {
carddav_folder: document.getElementById('modal-folder').value,
sync_interval_minutes: parseInt(document.getElementById('modal-interval').value),
sync_enabled: document.getElementById('modal-sync-enabled').value === 'true',
};
await fetch(`${API}/api/admin/users/${currentUserId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Einstellungen gespeichert');
await loadUsers();
}
async function syncUserNow() {
if (!currentUserId) return;
const btn = document.getElementById('modal-sync-btn');
btn.disabled = true;
btn.textContent = '⟳ Läuft…';
const resp = await fetch(`${API}/api/admin/users/${currentUserId}/sync-now`, {
method: 'POST',
credentials: 'include',
});
const data = await resp.json();
showToast(data.status === 'success' ? `${data.message}` : `${data.message}`, data.status);
btn.disabled = false;
btn.textContent = '⚡ Jetzt synchronisieren';
// Logs neu laden
const logsResp = await fetch(`${API}/api/admin/users/${currentUserId}/logs`, { credentials: 'include' });
const logsData = await logsResp.json();
renderModalLogs(logsData.logs);
await loadUsers();
}
async function deleteUser() {
if (!currentUserId) return;
const user = usersCache.find(u => u.id === currentUserId);
if (!confirm(`Benutzer "${user?.email}" wirklich löschen? Alle Sync-Daten werden entfernt.`)) return;
await fetch(`${API}/api/admin/users/${currentUserId}`, {
method: 'DELETE',
credentials: 'include',
});
closeModal();
showToast('Benutzer gelöscht');
await loadUsers();
}
async function loadConfig() {
try {
const resp = await fetch(`${API}/api/admin/config`, { credentials: 'include' });
const data = await resp.json();
if (data.configured) {
document.getElementById('cfg-url').value = data.server_url;
document.getElementById('cfg-user').value = data.username;
}
} catch {}
}
async function saveConfig() {
const payload = {
server_url: document.getElementById('cfg-url').value.trim(),
username: document.getElementById('cfg-user').value.trim(),
password: document.getElementById('cfg-pass').value,
};
if (!payload.server_url || !payload.username || !payload.password) {
showAlert('config-alert', 'Bitte alle Felder ausfüllen.', 'error');
return;
}
const resp = await fetch(`${API}/api/admin/config`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
showAlert('config-alert', '✓ Konfiguration gespeichert', 'success');
await loadFolderCache();
} else {
showAlert('config-alert', '✗ Fehler beim Speichern', 'error');
}
}
async function testConfig() {
const payload = {
server_url: document.getElementById('cfg-url').value.trim(),
username: document.getElementById('cfg-user').value.trim(),
password: document.getElementById('cfg-pass').value,
};
showAlert('config-alert', '⟳ Verbindung wird getestet…', 'info');
const resp = await fetch(`${API}/api/admin/config/test`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
showAlert('config-alert', data.ok ? `${data.message}` : `${data.message}`, data.ok ? 'success' : 'error');
}
function showAlert(id, msg, type) {
const el = document.getElementById(id);
el.className = `alert ${type}`;
el.textContent = msg;
el.style.display = 'flex';
}
// ── Azure AD Gruppe ────────────────────────────────────────
async function loadAzureGroup() {
// Folder-Dropdown befüllen
const folderSel = document.getElementById('az-default-folder');
folderSel.innerHTML = '<option value="">— Adressbuch wählen —</option>';
folderCache.forEach(f => {
const opt = document.createElement('option');
opt.value = f.url;
opt.textContent = f.name;
folderSel.appendChild(opt);
});
try {
const resp = await fetch(`${API}/api/admin/azure-group`, { credentials: 'include' });
const data = await resp.json();
if (!data.configured) return;
document.getElementById('az-group-id').value = data.group_id || '';
document.getElementById('az-auto-sync').value = String(data.auto_sync_members);
document.getElementById('az-sync-interval').value = data.member_sync_interval_hours || 6;
document.getElementById('az-default-folder').value = data.default_carddav_folder || '';
document.getElementById('az-default-interval').value = data.default_sync_interval_minutes || 60;
document.getElementById('az-default-enabled').value = String(data.default_sync_enabled);
if (data.last_member_sync_at) {
const statusEl = document.getElementById('az-status');
statusEl.style.display = 'block';
document.getElementById('az-status-title').textContent =
`Letzter Member-Sync: ${data.last_member_sync_status === 'success' ? '✓ Erfolgreich' : '✗ Fehler'}` +
(data.group_name ? ` — Gruppe: ${data.group_name}` : '');
document.getElementById('az-status-message').textContent = data.last_member_sync_message || '—';
document.getElementById('az-status-time').textContent = new Date(data.last_member_sync_at).toLocaleString('de-DE');
}
} catch (e) {
console.error('loadAzureGroup error:', e);
}
}
function _azurePayload() {
return {
group_id: document.getElementById('az-group-id').value.trim(),
auto_sync_members: document.getElementById('az-auto-sync').value === 'true',
member_sync_interval_hours: parseInt(document.getElementById('az-sync-interval').value),
default_carddav_folder: document.getElementById('az-default-folder').value || null,
default_sync_interval_minutes: parseInt(document.getElementById('az-default-interval').value),
default_sync_enabled: document.getElementById('az-default-enabled').value === 'true',
};
}
async function saveAzureGroup() {
const payload = _azurePayload();
if (!payload.group_id) {
showAlert('azure-alert', 'Bitte Gruppen-ID eintragen', 'error');
return;
}
const resp = await fetch(`${API}/api/admin/azure-group`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
showAlert('azure-alert', '✓ Konfiguration gespeichert', 'success');
} else {
showAlert('azure-alert', '✗ Fehler beim Speichern', 'error');
}
}
async function testAzureGroup() {
const payload = _azurePayload();
if (!payload.group_id) {
showAlert('azure-alert', 'Bitte Gruppen-ID eintragen', 'error');
return;
}
showAlert('azure-alert', '⟳ Teste Verbindung zu Azure AD…', 'info');
const resp = await fetch(`${API}/api/admin/azure-group/test`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
showAlert('azure-alert', (data.ok ? '✓ ' : '✗ ') + data.message, data.ok ? 'success' : 'error');
}
async function syncGroupNow() {
const btn = document.getElementById('az-sync-btn');
btn.disabled = true;
btn.textContent = '⟳ Importiere…';
try {
const resp = await fetch(`${API}/api/admin/azure-group/sync-now`, {
method: 'POST',
credentials: 'include',
});
const data = await resp.json();
showAlert('azure-alert', (data.status === 'success' ? '✓ ' : '✗ ') + data.message, data.status === 'success' ? 'success' : 'error');
await loadAzureGroup();
await loadUsers();
} catch (e) {
showAlert('azure-alert', '✗ Fehler: ' + e.message, 'error');
}
btn.disabled = false;
btn.textContent = '⚡ Mitglieder jetzt importieren';
}
async function deleteAzureGroup() {
if (!confirm('Gruppen-Konfiguration entfernen? Die bereits importierten User bleiben bestehen, aber es findet kein automatischer Member-Abgleich mehr statt.')) return;
await fetch(`${API}/api/admin/azure-group`, {
method: 'DELETE',
credentials: 'include',
});
showAlert('azure-alert', '✓ Konfiguration entfernt', 'success');
document.getElementById('az-group-id').value = '';
document.getElementById('az-status').style.display = 'none';
}
async function clearCache() {
const resp = await fetch(`${API}/api/admin/cache/clear`, {
method: 'POST',
credentials: 'include',
});
if (resp.ok) {
showAlert('config-alert', '✓ Cache geleert — nächste Syncs holen frische Daten', 'success');
} else {
showAlert('config-alert', '✗ Fehler beim Cache leeren', 'error');
}
}
async function logout() {
await fetch(`${API}/auth/logout`, { method: 'POST', credentials: 'include' });
window.location.href = '/';
}
function showToast(msg, type = 'success') {
const t = document.createElement('div');
t.style.cssText = `
position:fixed;bottom:24px;right:24px;z-index:9999;
padding:12px 20px;border-radius:10px;font-size:13px;font-weight:500;
background:${type==='success'?'rgba(62,207,142,0.15)':type==='error'?'rgba(247,108,108,0.15)':'rgba(79,142,247,0.15)'};
border:1px solid ${type==='success'?'rgba(62,207,142,0.3)':type==='error'?'rgba(247,108,108,0.3)':'rgba(79,142,247,0.3)'};
color:${type==='success'?'#3ecf8e':type==='error'?'#f76c6c':'#4f8ef7'};
font-family:'DM Sans',sans-serif;
`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 4000);
}
init();
</script>
</body>
</html>