- Replace Astro DB with better-sqlite3 (bypasses remote SQLite limitation) - Add consent API with auto-create table pattern - Update admin dashboard to fetch from API instead of Astro DB - Add IP hashing and rate limiting for GDPR compliance - Add seo-utils-mcp-guide skill - Update legal templates with business placeholders
383 lines
11 KiB
Plaintext
383 lines
11 KiB
Plaintext
---
|
|
// Password-protected admin page for viewing consent logs
|
|
// Uses API instead of Astro DB (better-sqlite3) to bypass remote SQLite limitation
|
|
|
|
const ADMIN_PASSWORD = Astro.env.ADMIN_PASSWORD || 'changeme';
|
|
|
|
let logs = [];
|
|
let isAuthenticated = false;
|
|
let error = '';
|
|
|
|
if (Astro.request.method === 'POST') {
|
|
const formData = await Astro.request.formData();
|
|
const password = formData.get('password');
|
|
|
|
if (password === ADMIN_PASSWORD) {
|
|
isAuthenticated = true;
|
|
try {
|
|
const response = await fetch('/api/consent');
|
|
const data = await response.json();
|
|
logs = data.logs || [];
|
|
} catch (err) {
|
|
error = 'Failed to load consent logs. Make sure the API is running.';
|
|
console.error(err);
|
|
}
|
|
} else {
|
|
error = 'Invalid password';
|
|
}
|
|
}
|
|
---
|
|
|
|
<html lang="th">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Consent Logs Admin | PDPA Compliance</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
background: #f3f4f6;
|
|
padding: 2rem;
|
|
}
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
h1 {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
margin-bottom: 1.5rem;
|
|
color: #111827;
|
|
}
|
|
.login-form {
|
|
max-width: 400px;
|
|
background: white;
|
|
padding: 2rem;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
}
|
|
.form-group {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
label {
|
|
display: block;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
margin-bottom: 0.5rem;
|
|
color: #374151;
|
|
}
|
|
input[type="password"] {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.375rem;
|
|
font-size: 1rem;
|
|
}
|
|
input[type="password"]:focus {
|
|
outline: none;
|
|
border-color: #2563eb;
|
|
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
|
|
}
|
|
button {
|
|
width: 100%;
|
|
padding: 0.75rem 1.5rem;
|
|
background: #2563eb;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
button:hover {
|
|
background: #1d4ed8;
|
|
}
|
|
.error {
|
|
background: #fee2e2;
|
|
color: #dc2626;
|
|
padding: 0.75rem;
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 1rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.success {
|
|
background: #dcfce7;
|
|
color: #16a34a;
|
|
padding: 0.75rem;
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 1rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
overflow: hidden;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
}
|
|
th, td {
|
|
padding: 1rem;
|
|
text-align: left;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
th {
|
|
background: #f9fafb;
|
|
font-weight: 600;
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: #6b7280;
|
|
}
|
|
tr:hover {
|
|
background: #f9fafb;
|
|
}
|
|
.actions {
|
|
margin-bottom: 1rem;
|
|
display: flex;
|
|
gap: 1rem;
|
|
}
|
|
.btn {
|
|
display: inline-block;
|
|
padding: 0.5rem 1rem;
|
|
font-size: 0.875rem;
|
|
border-radius: 0.375rem;
|
|
text-decoration: none;
|
|
transition: background 0.2s;
|
|
}
|
|
.btn-primary {
|
|
background: #2563eb;
|
|
color: white;
|
|
}
|
|
.btn-primary:hover {
|
|
background: #1d4ed8;
|
|
}
|
|
.btn-success {
|
|
background: #16a34a;
|
|
color: white;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
.btn-success:hover {
|
|
background: #15803d;
|
|
}
|
|
.btn-danger {
|
|
background: #dc2626;
|
|
color: white;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
.btn-danger:hover {
|
|
background: #b91c1c;
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.75rem;
|
|
border-radius: 9999px;
|
|
font-weight: 500;
|
|
}
|
|
.badge-green {
|
|
background: #dcfce7;
|
|
color: #16a34a;
|
|
}
|
|
.badge-red {
|
|
background: #fee2e2;
|
|
color: #dc2626;
|
|
}
|
|
.info-box {
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
background: #fef3c7;
|
|
border-radius: 0.375rem;
|
|
}
|
|
.info-box h3 {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
color: #92400e;
|
|
}
|
|
.info-box ul {
|
|
font-size: 0.75rem;
|
|
color: #92400e;
|
|
list-style: disc;
|
|
padding-left: 1.5rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🔐 Consent Logs Admin Dashboard</h1>
|
|
|
|
{!isAuthenticated ? (
|
|
<div class="login-form">
|
|
<h2 style="font-size: 1.25rem; font-weight: bold; margin-bottom: 1rem;">Admin Login</h2>
|
|
{error && <div class="error">{error}</div>}
|
|
<form method="POST">
|
|
<div class="form-group">
|
|
<label for="password">Password</label>
|
|
<input
|
|
type="password"
|
|
id="password"
|
|
name="password"
|
|
required
|
|
placeholder="Enter admin password"
|
|
/>
|
|
</div>
|
|
<button type="submit">Login</button>
|
|
</form>
|
|
<p style="margin-top: 1rem; font-size: 0.875rem; color: #6b7280;">
|
|
Default password: <code>changeme</code> (change in .env)
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div class="actions">
|
|
<a href="/admin/consent-logs" class="btn btn-primary">Refresh</a>
|
|
<button class="btn btn-success" onclick="exportCSV()">Export CSV</button>
|
|
<a href="/" class="btn" style="background: #6b7280; color: white;">← Back to Site</a>
|
|
</div>
|
|
|
|
{error && <div class="error">{error}</div>}
|
|
|
|
<div style="overflow-x: auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Date/Time</th>
|
|
<th>Session ID</th>
|
|
<th>Essential</th>
|
|
<th>Analytics</th>
|
|
<th>Marketing</th>
|
|
<th>Policy Ver</th>
|
|
<th>IP Hash</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.length === 0 ? (
|
|
<tr>
|
|
<td colspan="8" style="text-align: center; padding: 2rem;">
|
|
No consent logs found. Make sure the website has received consent.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
logs.map((log) => (
|
|
<tr>
|
|
<td>{new Date(log.timestamp).toLocaleString('th-TH')}</td>
|
|
<td style="font-family: monospace; font-size: 0.75rem;">{log.sessionId}</td>
|
|
<td>
|
|
<span class="badge badge-green">{log.essential ? 'Yes' : 'No'}</span>
|
|
</td>
|
|
<td>
|
|
{log.analytics ? (
|
|
<span class="badge badge-green">✓</span>
|
|
) : (
|
|
<span class="badge badge-red">✗</span>
|
|
)}
|
|
</td>
|
|
<td>
|
|
{log.marketing ? (
|
|
<span class="badge badge-green">✓</span>
|
|
) : (
|
|
<span class="badge badge-red">✗</span>
|
|
)}
|
|
</td>
|
|
<td>{log.policyVersion}</td>
|
|
<td style="font-family: monospace; font-size: 0.75rem;">{log.ipHash}</td>
|
|
<td>
|
|
<button
|
|
class="btn btn-danger"
|
|
onclick="deleteConsent('{log.sessionId}')"
|
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="info-box">
|
|
<h3>⚠️ Important Notes (PDPA Compliance):</h3>
|
|
<ul>
|
|
<li>Consent records must be retained for 10 years</li>
|
|
<li>Only delete records when user exercises "right to be forgotten"</li>
|
|
<li>IP addresses are hashed (SHA-256, first 16 chars) for privacy</li>
|
|
<li>Rate limiting: 10 requests/minute per IP</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<script>
|
|
async function deleteConsent(sessionId) {
|
|
if (!confirm('Delete this consent record? This action cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/consent/${sessionId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Consent record deleted successfully');
|
|
location.reload();
|
|
} else {
|
|
alert('Failed to delete consent record');
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete error:', error);
|
|
alert('Error deleting consent record');
|
|
}
|
|
}
|
|
|
|
async function exportCSV() {
|
|
try {
|
|
const response = await fetch('/api/consent');
|
|
const data = await response.json();
|
|
const logs = data.logs || [];
|
|
|
|
const headers = ['วันที่/เวลา', 'Session ID', 'Essential', 'Analytics', 'Marketing', 'Policy Version', 'IP Hash', 'User Agent'];
|
|
const csvRows = [headers.join(',')];
|
|
|
|
for (const log of logs) {
|
|
const row = [
|
|
new Date(log.timestamp).toLocaleString('th-TH'),
|
|
log.sessionId,
|
|
log.essential ? 'Yes' : 'No',
|
|
log.analytics ? 'Yes' : 'No',
|
|
log.marketing ? 'Yes' : 'No',
|
|
log.policyVersion,
|
|
log.ipHash || '',
|
|
(log.userAgent || '').replace(/,/g, ';')
|
|
];
|
|
csvRows.push(row.join(','));
|
|
}
|
|
|
|
// UTF-8 BOM for Excel compatibility
|
|
const csvContent = '\ufeff' + csvRows.join('\n');
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `consent-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
|
link.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error('Export error:', error);
|
|
alert('Failed to export CSV');
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|