Files
files/nextjs/app/manage/admin/dashboard/page.js
Ludwig Lehnert a2b6054440 feat: improve upload admin workflow
Add users.is_admin SQLite migration for promoted admins.

Support curl PUT request uploads, save uploads into admin file-manager paths, default 25 GiB uploads, tabbed UI, and local dev preview.
2026-06-18 17:09:07 +00:00

373 lines
15 KiB
JavaScript

import {
adminDeleteUploadAction,
adminExtendUploadAction,
adminLogoutAction,
adminSaveUploadToPathAction,
} from '@/src/lib/actions.js';
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
import { sharedLinkName } from '@/src/lib/files.js';
import {
formatBytes,
formatCountdown,
formatTimestamp,
parseLogDetail,
readSearchParam,
} from '@/src/lib/format.js';
import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js';
import { CopyLinkButton } from '../../_components/copy-link-button.js';
import { StatusMessage } from '../../_components/status-message.js';
export const dynamic = 'force-dynamic';
export default async function AdminDashboardPage({ searchParams }) {
await runCleanupIfNeeded();
await requireAdminUser();
const csrfToken = await ensureCsrfToken();
const [
activeCount,
activeBytes,
distinctOwners,
totalUploads,
totalDownloads,
totalDeletes,
lastCleanup,
recentLogs,
allUploads,
uploadRequests,
] = await Promise.all([
get('SELECT COUNT(*) AS count FROM uploads'),
get('SELECT COALESCE(SUM(size_bytes), 0) AS total FROM uploads'),
get('SELECT COUNT(DISTINCT owner) AS count FROM uploads'),
get('SELECT COUNT(*) AS count FROM admin_logs WHERE event = ?', ['upload']),
get('SELECT COALESCE(SUM(downloads), 0) AS count FROM uploads'),
get('SELECT COUNT(*) AS count FROM admin_logs WHERE event IN (?, ?, ?)', [
'delete',
'cleanup',
'admin_delete',
]),
get('SELECT MAX(created_at) AS ts FROM admin_logs WHERE event = ?', ['cleanup']),
all(
'SELECT event, owner, detail, created_at, ip, user_agent FROM admin_logs ORDER BY created_at DESC LIMIT 250'
),
all(
'SELECT id, owner, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads ORDER BY uploaded_at DESC LIMIT 500'
),
all(
`SELECT id, owner, note, created_at, expires_at, completed_at, upload_id, uploaded_original_name, uploaded_size_bytes
FROM upload_requests
ORDER BY created_at DESC
LIMIT 500`
),
]);
const resolvedSearchParams = await searchParams;
const error = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = ['stats', 'uploads', 'requests', 'logs'].includes(requestedTab) ? requestedTab : 'stats';
return (
<main className="page-shell">
<header className="page-header">
<div className="header-main">
<h1>Adminübersicht</h1>
<p className="muted">Metriken, Ereignisse und direkte Eingriffe in Uploads.</p>
</div>
<div className="toolbar">
<a className="chip primary" href="/manage/admin/files">
Dateimanager
</a>
<a className="chip primary" href="/manage/admin/users">
Benutzer verwalten
</a>
<form className="inline-form" action={adminLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</div>
</header>
<StatusMessage error={error} success={success} />
<nav className="tabs" aria-label="Admin-Bereiche">
<a className={`tab ${activeTab === 'stats' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=stats">
Statistik
</a>
<a className={`tab ${activeTab === 'uploads' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=uploads">
Uploads
</a>
<a className={`tab ${activeTab === 'requests' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=requests">
Anfragen
</a>
<a className={`tab ${activeTab === 'logs' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=logs">
Logs
</a>
</nav>
{activeTab === 'stats' ? (
<section className="panel">
<h2>Statistiken</h2>
<div className="metric-grid">
<article className="metric">
<span className="muted">Aktive Uploads</span>
<strong>{activeCount?.count || 0}</strong>
</article>
<article className="metric">
<span className="muted">Aktive Dateigröße</span>
<strong>{formatBytes(activeBytes?.total || 0)}</strong>
</article>
<article className="metric">
<span className="muted">Aktive Nutzer</span>
<strong>{distinctOwners?.count || 0}</strong>
</article>
<article className="metric">
<span className="muted">Uploads gesamt</span>
<strong>{totalUploads?.count || 0}</strong>
</article>
<article className="metric">
<span className="muted">Downloads gesamt</span>
<strong>{totalDownloads?.count || 0}</strong>
</article>
<article className="metric">
<span className="muted">Löschungen gesamt</span>
<strong>{totalDeletes?.count || 0}</strong>
</article>
<article className="metric">
<span className="muted">Letztes Cleanup</span>
<strong>{lastCleanup?.ts ? formatTimestamp(lastCleanup.ts) : '-'}</strong>
</article>
</div>
</section>
) : null}
{activeTab === 'uploads' ? (
<section className="panel">
<h2>Aktuelle Uploads</h2>
{allUploads.length === 0 ? (
<p className="muted">Noch keine Uploads vorhanden.</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Nutzer</th>
<th>Datei</th>
<th>Größe</th>
<th>Hochgeladen</th>
<th>Ablauf</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{allUploads.map((item) => {
const shareName = sharedLinkName(item.stored_name);
const sharePath = `/_share/${encodeURIComponent(shareName)}`;
return (
<tr key={item.id}>
<td>{item.owner}</td>
<td>
<strong>{item.original_name}</strong>
<div className="muted mono">{item.stored_name}</div>
</td>
<td>{formatBytes(item.size_bytes)}</td>
<td>{formatTimestamp(item.uploaded_at)}</td>
<td>
<div>{formatTimestamp(item.expires_at)}</div>
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
</td>
<td>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<div className="row-actions">
<a className="btn secondary" href={sharePath}>
Download
</a>
<CopyLinkButton path={sharePath} label={item.original_name} />
</div>
<form className="inline-form stacked" action={adminSaveUploadToPathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={item.id} />
<input type="hidden" name="redirectTab" value="uploads" />
<input
className="input small path-input"
name="targetPath"
placeholder="Ziel, z.B. inbox/"
required
/>
<button className="btn secondary" type="submit">
Im Dateimanager speichern
</button>
</form>
<form className="inline-form action-form-row">
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={item.id} />
<input className="input small" name="extendHours" placeholder="Stunden" />
<button className="btn" type="submit" formAction={adminExtendUploadAction}>
Verlängern
</button>
<button className="btn danger" type="submit" formAction={adminDeleteUploadAction}>
Löschen
</button>
</form>
</div>
</details>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
) : null}
{activeTab === 'requests' ? (
<section className="panel">
<h2>Upload-Anfragen</h2>
{uploadRequests.length === 0 ? (
<p className="muted">Keine Upload-Anfragen vorhanden.</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Anfrage</th>
<th>Nutzer</th>
<th>Status</th>
<th>Erstellt</th>
<th>Ablauf</th>
<th>Datei</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{uploadRequests.map((entry) => {
const isCompleted = Number(entry.completed_at || 0) > 0;
const isExpired = !isCompleted && Number(entry.expires_at || 0) <= Date.now();
const requestPath = `/_request/${encodeURIComponent(entry.id)}`;
return (
<tr key={entry.id}>
<td>
<strong>{entry.id}</strong>
{entry.note ? <div className="muted">{entry.note}</div> : null}
</td>
<td>{entry.owner}</td>
<td>
<span className={`badge ${isCompleted ? 'success' : isExpired ? 'danger' : 'primary'}`}>
{isCompleted ? 'Abgeschlossen' : isExpired ? 'Abgelaufen' : 'Offen'}
</span>
</td>
<td>{formatTimestamp(entry.created_at)}</td>
<td>{formatTimestamp(entry.expires_at)}</td>
<td>
{entry.uploaded_original_name ? (
<>
<strong>{entry.uploaded_original_name}</strong>
<div className="muted">{formatBytes(entry.uploaded_size_bytes || 0)}</div>
</>
) : (
<span className="muted">-</span>
)}
</td>
<td>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<a className="btn secondary" href={requestPath}>
Öffnen
</a>
{entry.upload_id ? (
<form className="inline-form stacked" action={adminSaveUploadToPathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={entry.upload_id} />
<input type="hidden" name="redirectTab" value="requests" />
<input
className="input small path-input"
name="targetPath"
placeholder="Ziel, z.B. inbox/"
required
/>
<button className="btn secondary" type="submit">
Im Dateimanager speichern
</button>
</form>
) : null}
</div>
</details>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
) : null}
{activeTab === 'logs' ? (
<section className="panel">
<h2>Letzte Ereignisse</h2>
{recentLogs.length === 0 ? (
<p className="muted">Keine Logs vorhanden.</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Zeit</th>
<th>Event</th>
<th>Nutzer</th>
<th>IP</th>
<th>Details</th>
<th>User-Agent</th>
</tr>
</thead>
<tbody>
{recentLogs.map((entry, index) => {
const details = parseLogDetail(entry.detail);
return (
<tr key={`${entry.created_at}-${entry.event}-${index}`}>
<td>{formatTimestamp(entry.created_at)}</td>
<td>{entry.event}</td>
<td>{entry.owner || '-'}</td>
<td className="mono">{entry.ip || '-'}</td>
<td>
{details.length === 0 ? (
<span className="muted">-</span>
) : (
<div className="stack-actions">
{details.map((detail) => (
<div key={`${detail.key}-${detail.value}`}>
<strong>{detail.key}:</strong> <span>{detail.value}</span>
</div>
))}
</div>
)}
</td>
<td className="mono">{entry.user_agent || '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
) : null}
</main>
);
}