Add Google Analytics dashboard widget (GA4 Data API integration)

New Dashboard section shows users/sessions/pageviews for the last 7
days plus top pages, fetched server-side via the GA4 Data API using a
Google service account (JWT-bearer flow, no OAuth consent screen
needed) - the same approach WordPress plugins like MonsterInsights use
to surface GA stats inline instead of linking out to analytics.google.com.

Backend: GoogleAnalyticsReporting support class (signs its own JWT with
openssl, exchanges it for an access token, calls runReport - no need
for the full Google API PHP client library for one endpoint) and
AnalyticsController (settings show/update, report fetch). The service
account JSON key is written to a gitignored config file, mirroring the
existing db.php/setup.php pattern for environment-specific secrets.

Admin: new "Dashboard-Anbindung" section in Website Settings (Property
ID + service account JSON paste, independent save action). Dashboard
gracefully shows a setup hint when not yet configured instead of an
error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-07 01:57:32 +02:00
parent 93e245d3b9
commit 24df7a602c
9 changed files with 418 additions and 0 deletions

4
.gitignore vendored
View file

@ -28,6 +28,10 @@
# pro Umgebung unterschiedlich und darf nicht öffentlich einsehbar sein
/hifi/api/config/setup.php
# Google-Service-Account-Schlüssel für die Analytics-Data-API-Anbindung im Dashboard
# (wird vom Admin-Panel automatisch neu geschrieben, enthält einen privaten Schlüssel)
/hifi/api/config/ga-service-account.json
# Claude-Code-Tooling (Skills/lokale Einstellungen) - gehört nicht zum Website-Projekt
/hifi/.claude/
/hifi/.agents/

View file

@ -16,6 +16,94 @@ const Icon = ({ path, className = 'h-6 w-6' }) => (
</svg>
);
function AnalyticsWidget() {
const [report, setReport] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
api.get('/analytics/report').then(setReport).catch((e) => setError(e.message));
}, []);
if (error) {
return (
<section className="mt-8 rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2>
<p className="text-sm text-red-600">{error}</p>
</section>
);
}
if (!report) return null;
if (!report.configured) {
return (
<section className="mt-8 rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Noch nicht eingerichtet. Unter{' '}
<Link to="/admin/settings/website" className="text-brand-600 hover:underline dark:text-brand-400">
Einstellungen Website
</Link>{' '}
kannst du die Dashboard-Anbindung konfigurieren, um hier Besucherzahlen zu sehen.
</p>
</section>
);
}
const maxPageviews = Math.max(1, ...report.daily.map((d) => d.pageviews));
return (
<section className="mt-8 rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2>
<p className="mb-5 text-sm text-neutral-500 dark:text-neutral-400">Letzte 7 Tage.</p>
<div className="mb-6 grid grid-cols-3 gap-4">
<div>
<p className="text-2xl font-extrabold text-brand-600 dark:text-brand-400">{report.totals.users}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">Nutzer</p>
</div>
<div>
<p className="text-2xl font-extrabold text-brand-600 dark:text-brand-400">{report.totals.sessions}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">Sitzungen</p>
</div>
<div>
<p className="text-2xl font-extrabold text-brand-600 dark:text-brand-400">{report.totals.pageviews}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">Seitenaufrufe</p>
</div>
</div>
{report.daily.length > 0 && (
<div className="mb-6 flex h-24 items-end gap-1.5">
{report.daily.map((d) => (
<div key={d.date} className="flex flex-1 flex-col items-center gap-1">
<div
className="w-full rounded-t bg-brand-400 dark:bg-brand-500"
style={{ height: `${Math.max(4, (d.pageviews / maxPageviews) * 80)}px` }}
title={`${d.date}: ${d.pageviews} Seitenaufrufe`}
/>
<span className="text-[10px] text-neutral-400">{d.date.slice(6, 8)}.{d.date.slice(4, 6)}.</span>
</div>
))}
</div>
)}
{report.top_pages.length > 0 && (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Meistbesuchte Seiten</p>
<ul className="space-y-1 text-sm">
{report.top_pages.map((p) => (
<li key={p.path} className="flex items-center justify-between gap-3 text-neutral-700 dark:text-neutral-300">
<span className="truncate font-mono text-xs">{p.path}</span>
<span className="shrink-0 font-semibold">{p.pageviews}</span>
</li>
))}
</ul>
</div>
)}
</section>
);
}
export default function Dashboard() {
const { user, hasPermission } = useAuth();
const [stats, setStats] = useState({});
@ -70,6 +158,8 @@ export default function Dashboard() {
))}
</div>
)}
{hasPermission('settings.manage') && <AnalyticsWidget />}
</div>
);
}

View file

@ -11,6 +11,13 @@ export default function WebsiteSettings() {
const [saved, setSaved] = useState(false);
const [error, setError] = useState('');
const [gaPropertyId, setGaPropertyId] = useState('');
const [gaServiceAccountJson, setGaServiceAccountJson] = useState('');
const [gaHasCredentials, setGaHasCredentials] = useState(false);
const [gaBusy, setGaBusy] = useState(false);
const [gaError, setGaError] = useState('');
const [gaSaved, setGaSaved] = useState(false);
useEffect(() => {
api
.get('/site-settings')
@ -24,8 +31,31 @@ export default function WebsiteSettings() {
})
)
.finally(() => setLoading(false));
api.get('/settings/analytics').then((res) => {
setGaPropertyId(res.ga_property_id || '');
setGaHasCredentials(res.has_credentials);
});
}, []);
const handleGaDashboardSave = async () => {
setGaBusy(true);
setGaError('');
setGaSaved(false);
try {
const res = await api.post('/settings/analytics', {
ga_property_id: gaPropertyId,
service_account_json: gaServiceAccountJson,
});
setGaHasCredentials(res.has_credentials);
setGaServiceAccountJson('');
setGaSaved(true);
} catch (err) {
setGaError(err.message);
} finally {
setGaBusy(false);
}
};
const update = (field, value) => {
setForm((f) => ({ ...f, [field]: value }));
setSaved(false);
@ -119,6 +149,48 @@ export default function WebsiteSettings() {
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div className="mt-5 border-t border-neutral-200 pt-5 dark:border-neutral-800">
<h3 className="mb-1 text-sm font-semibold text-neutral-900 dark:text-white">Dashboard-Anbindung (optional)</h3>
<p className="mb-3 text-sm text-neutral-500 dark:text-neutral-400">
Zeigt Besucherzahlen direkt in deinem Admin-Dashboard an. Braucht ein Google-Cloud-Service-Account mit
Lesezugriff auf diese GA4-Property.
</p>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Property-ID</label>
<input
value={gaPropertyId}
onChange={(e) => { setGaPropertyId(e.target.value); setGaSaved(false); }}
placeholder="z. B. 123456789"
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
<p className="mt-1 text-xs text-neutral-400">Nicht die Measurement-ID (G-...) - zu finden unter GA4 Verwaltung Property-Details.</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Service-Account-JSON-Schlüssel {gaHasCredentials ? '(leer lassen = unverändert)' : ''}
</label>
<textarea
rows={4}
value={gaServiceAccountJson}
onChange={(e) => { setGaServiceAccountJson(e.target.value); setGaSaved(false); }}
placeholder={gaHasCredentials ? '{ "type": "service_account", ... } (bereits hinterlegt)' : '{ "type": "service_account", ... }'}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 font-mono text-xs dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
</div>
{gaError && <p className="mt-2 text-sm text-red-600">{gaError}</p>}
{gaSaved && <p className="mt-2 text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
<button
type="button"
onClick={handleGaDashboardSave}
disabled={gaBusy}
className="mt-3 rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
{gaBusy ? 'Speichere…' : 'Dashboard-Anbindung speichern'}
</button>
</div>
</section>
{error && <p className="text-sm text-red-600">{error}</p>}

View file

@ -0,0 +1,7 @@
-- GA4-Property-ID admin-konfigurierbar machen, fuer die Dashboard-Kennzahlen-Anbindung
-- ueber die Google Analytics Data API. Additiv, nichts wird geloescht.
-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren"
-- unter Admin-Panel -> Einstellungen -> Datenbank.
ALTER TABLE app_settings
ADD COLUMN IF NOT EXISTS ga_property_id VARCHAR(30) NULL;

View file

@ -25,6 +25,7 @@ CREATE TABLE app_settings (
contact_email VARCHAR(150) NULL,
hero_image_path VARCHAR(255) NULL,
ga_measurement_id VARCHAR(20) NULL,
ga_property_id VARCHAR(30) NULL,
mail_host VARCHAR(255) NULL,
mail_port INT NULL,
mail_username VARCHAR(255) NULL,

View file

@ -9,6 +9,7 @@ ob_start();
require __DIR__ . '/../vendor/autoload.php';
use App\Controllers\AdminUserController;
use App\Controllers\AnalyticsController;
use App\Controllers\AuthController;
use App\Controllers\BrandController;
use App\Controllers\ContactController;
@ -179,6 +180,10 @@ $router->get('/settings/mail', $perm('settings.manage', fn($p) => MailSettingsCo
$router->post('/settings/mail', $perm('settings.manage', fn($p) => MailSettingsController::update()));
$router->post('/settings/mail/test', $perm('settings.manage', fn($p) => MailSettingsController::test()));
$router->get('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::show()));
$router->post('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::update()));
$router->get('/analytics/report', $admin(fn($p) => AnalyticsController::report()));
$router->get('/maintenance', fn($p) => MaintenanceController::status());
$router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update()));

View file

@ -0,0 +1,75 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Support\GoogleAnalyticsReporting;
use App\Support\Http;
class AnalyticsController
{
public static function show(): void
{
$propertyId = null;
try {
$stmt = Database::connection()->query('SELECT ga_property_id FROM app_settings WHERE id = 1');
$row = $stmt->fetch();
$propertyId = ($row['ga_property_id'] ?? '') !== '' ? $row['ga_property_id'] : null;
} catch (\Throwable $e) {
// Spalte fehlt noch (vor Schema-Migration) - Default (null) bleibt bestehen.
}
Http::send([
'ga_property_id' => $propertyId,
'has_credentials' => GoogleAnalyticsReporting::hasCredentials(),
]);
}
public static function update(): void
{
$body = Http::jsonBody();
$propertyId = trim($body['ga_property_id'] ?? '');
$serviceAccountJson = trim($body['service_account_json'] ?? '');
if ($serviceAccountJson !== '') {
$decoded = json_decode($serviceAccountJson, true);
if (!is_array($decoded) || empty($decoded['client_email']) || empty($decoded['private_key'])) {
Http::error('Das eingefügte JSON sieht nicht wie ein gültiger Google-Service-Account-Schlüssel aus (client_email/private_key fehlen).', 422);
}
if (@file_put_contents(GoogleAnalyticsReporting::credentialsPath(), $serviceAccountJson) === false) {
Http::error('Service-Account-JSON konnte nicht gespeichert werden (Dateirechte prüfen).', 500);
}
}
$db = Database::connection();
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
$stmt = $db->prepare('UPDATE app_settings SET ga_property_id = ? WHERE id = 1');
$stmt->execute([$propertyId ?: null]);
self::show();
}
public static function report(): void
{
$propertyId = null;
try {
$stmt = Database::connection()->query('SELECT ga_property_id FROM app_settings WHERE id = 1');
$row = $stmt->fetch();
$propertyId = ($row['ga_property_id'] ?? '') !== '' ? $row['ga_property_id'] : null;
} catch (\Throwable $e) {
// Spalte fehlt noch (vor Schema-Migration).
}
if (!$propertyId || !GoogleAnalyticsReporting::hasCredentials()) {
Http::send(['configured' => false]);
}
try {
$report = GoogleAnalyticsReporting::fetchDashboardReport($propertyId);
} catch (\Throwable $e) {
Http::error($e->getMessage(), 502);
}
Http::send(['configured' => true] + $report);
}
}

View file

@ -0,0 +1,162 @@
<?php
namespace App\Support;
/**
* Serverseitige Anbindung an die Google Analytics Data API (GA4), damit sich Kennzahlen
* im eigenen Admin-Dashboard anzeigen lassen - wie z.B. MonsterInsights das fuer WordPress
* macht. Authentifizierung per Service-Account (JWT-Bearer-Flow), bewusst ohne die grosse
* offizielle Google-API-PHP-Client-Bibliothek: nur ein signiertes JWT + zwei HTTP-Aufrufe,
* das reicht fuer diesen einen Report-Endpunkt komplett aus.
*/
class GoogleAnalyticsReporting
{
private const TOKEN_URI = 'https://oauth2.googleapis.com/token';
private const SCOPE = 'https://www.googleapis.com/auth/analytics.readonly';
private const API_BASE = 'https://analyticsdata.googleapis.com/v1beta';
public static function credentialsPath(): string
{
return __DIR__ . '/../../config/ga-service-account.json';
}
public static function hasCredentials(): bool
{
return is_file(self::credentialsPath());
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function credentials(): array
{
$raw = file_get_contents(self::credentialsPath());
$data = json_decode((string) $raw, true);
if (!is_array($data) || empty($data['client_email']) || empty($data['private_key'])) {
throw new \RuntimeException('Service-Account-JSON ist unvollständig oder ungültig.');
}
return $data;
}
/**
* Server-zu-Server-Authentifizierung per JWT-Bearer-Flow - kein Nutzer-Login/Consent-Screen
* noetig, siehe https://developers.google.com/identity/protocols/oauth2/service-account.
*/
private static function fetchAccessToken(array $credentials): string
{
$now = time();
$header = self::base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claims = self::base64UrlEncode(json_encode([
'iss' => $credentials['client_email'],
'scope' => self::SCOPE,
'aud' => self::TOKEN_URI,
'iat' => $now,
'exp' => $now + 3600,
]));
$signatureInput = $header . '.' . $claims;
$privateKey = openssl_pkey_get_private($credentials['private_key']);
if ($privateKey === false) {
throw new \RuntimeException('Privater Schlüssel im Service-Account-JSON ist ungültig.');
}
openssl_sign($signatureInput, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$jwt = $signatureInput . '.' . self::base64UrlEncode($signature);
$response = self::post(self::TOKEN_URI, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]), ['Content-Type: application/x-www-form-urlencoded']);
if (empty($response['access_token'])) {
throw new \RuntimeException('Google-Token-Anfrage fehlgeschlagen: ' . ($response['error_description'] ?? $response['error'] ?? 'unbekannter Fehler'));
}
return $response['access_token'];
}
private static function post(string $url, string $body, array $headers): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
if ($raw === false) {
$error = curl_error($ch);
curl_close($ch);
throw new \RuntimeException('Verbindung zu Google fehlgeschlagen: ' . $error);
}
curl_close($ch);
$data = json_decode((string) $raw, true);
return is_array($data) ? $data : [];
}
private static function runReport(string $propertyId, string $token, array $body): array
{
$response = self::post(
self::API_BASE . '/properties/' . $propertyId . ':runReport',
json_encode($body),
['Authorization: Bearer ' . $token, 'Content-Type: application/json']
);
if (isset($response['error'])) {
throw new \RuntimeException('Google Analytics Data API: ' . ($response['error']['message'] ?? 'unbekannter Fehler'));
}
return $response;
}
/**
* Kennzahlen (Nutzer/Sitzungen/Seitenaufrufe je Tag) sowie die meistbesuchten Seiten
* der letzten $days Tage - genau das, was das Dashboard-Widget anzeigt.
*/
public static function fetchDashboardReport(string $propertyId, int $days = 7): array
{
$credentials = self::credentials();
$token = self::fetchAccessToken($credentials);
$daily = self::runReport($propertyId, $token, [
'dateRanges' => [['startDate' => $days . 'daysAgo', 'endDate' => 'today']],
'dimensions' => [['name' => 'date']],
'metrics' => [['name' => 'activeUsers'], ['name' => 'sessions'], ['name' => 'screenPageViews']],
'orderBys' => [['dimension' => ['dimensionName' => 'date']]],
]);
$topPages = self::runReport($propertyId, $token, [
'dateRanges' => [['startDate' => $days . 'daysAgo', 'endDate' => 'today']],
'dimensions' => [['name' => 'pagePath']],
'metrics' => [['name' => 'screenPageViews']],
'orderBys' => [['metric' => ['metricName' => 'screenPageViews'], 'desc' => true]],
'limit' => 5,
]);
$dailyRows = array_map(fn($row) => [
'date' => $row['dimensionValues'][0]['value'],
'users' => (int) $row['metricValues'][0]['value'],
'sessions' => (int) $row['metricValues'][1]['value'],
'pageviews' => (int) $row['metricValues'][2]['value'],
], $daily['rows'] ?? []);
$topPageRows = array_map(fn($row) => [
'path' => $row['dimensionValues'][0]['value'],
'pageviews' => (int) $row['metricValues'][0]['value'],
], $topPages['rows'] ?? []);
return [
'daily' => $dailyRows,
'top_pages' => $topPageRows,
'totals' => [
'users' => array_sum(array_column($dailyRows, 'users')),
'sessions' => array_sum(array_column($dailyRows, 'sessions')),
'pageviews' => array_sum(array_column($dailyRows, 'pageviews')),
],
];
}
}

View file

@ -36,6 +36,7 @@ class Schema
contact_email VARCHAR(150) NULL,
hero_image_path VARCHAR(255) NULL,
ga_measurement_id VARCHAR(20) NULL,
ga_property_id VARCHAR(30) NULL,
mail_host VARCHAR(255) NULL,
mail_port INT NULL,
mail_username VARCHAR(255) NULL,
@ -244,6 +245,7 @@ class Schema
'contact_email' => 'VARCHAR(150) NULL',
'hero_image_path' => 'VARCHAR(255) NULL',
'ga_measurement_id' => 'VARCHAR(20) NULL',
'ga_property_id' => 'VARCHAR(30) NULL',
'mail_host' => 'VARCHAR(255) NULL',
'mail_port' => 'INT NULL',
'mail_username' => 'VARCHAR(255) NULL',