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.
This commit is contained in:
Ludwig Lehnert
2026-06-18 17:09:07 +00:00
parent 61f10fd994
commit a2b6054440
19 changed files with 1148 additions and 396 deletions

View File

@@ -2,8 +2,8 @@ import {
adminDeleteUploadAction,
adminExtendUploadAction,
adminLogoutAction,
adminSaveUploadToPathAction,
} from '@/src/lib/actions.js';
import { adminHash } from '@/src/lib/config.js';
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
import { sharedLinkName } from '@/src/lib/files.js';
import {
@@ -23,20 +23,6 @@ export const dynamic = 'force-dynamic';
export default async function AdminDashboardPage({ searchParams }) {
await runCleanupIfNeeded();
if (!adminHash) {
return (
<main className="page-shell narrow">
<section className="panel centered">
<h1>Adminzugang nicht konfiguriert</h1>
<p className="muted">Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.</p>
<a className="btn secondary" href="/manage/login">
Zurück
</a>
</section>
</main>
);
}
await requireAdminUser();
const csrfToken = await ensureCsrfToken();
@@ -50,6 +36,7 @@ export default async function AdminDashboardPage({ searchParams }) {
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'),
@@ -68,11 +55,19 @@ export default async function AdminDashboardPage({ searchParams }) {
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">
@@ -100,6 +95,22 @@ export default async function AdminDashboardPage({ searchParams }) {
<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">
@@ -133,7 +144,9 @@ export default async function AdminDashboardPage({ searchParams }) {
</article>
</div>
</section>
) : null}
{activeTab === 'uploads' ? (
<section className="panel">
<h2>Aktuelle Uploads</h2>
{allUploads.length === 0 ? (
@@ -169,7 +182,9 @@ export default async function AdminDashboardPage({ searchParams }) {
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
</td>
<td>
<div className="stack-actions">
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<div className="row-actions">
<a className="btn secondary" href={sharePath}>
Download
@@ -177,6 +192,21 @@ export default async function AdminDashboardPage({ searchParams }) {
<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} />
@@ -188,7 +218,8 @@ export default async function AdminDashboardPage({ searchParams }) {
Löschen
</button>
</form>
</div>
</div>
</details>
</td>
</tr>
);
@@ -198,7 +229,95 @@ export default async function AdminDashboardPage({ searchParams }) {
</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 ? (
@@ -247,6 +366,7 @@ export default async function AdminDashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}