mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
feat: implement file deletion functionality and enhance deployment script
This commit is contained in:
+29
-12
@@ -1,22 +1,39 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# deploy.sh — Build the image and start (or restart) the local stack.
|
# deploy.sh — Build the image locally and start (or restart) the stack.
|
||||||
# Usage: ./docker/deploy.sh [TAG]
|
#
|
||||||
# TAG defaults to "backerup-website:latest"
|
# Usage:
|
||||||
|
# ./docker/deploy.sh # rebuild & restart, keep existing data
|
||||||
|
# ./docker/deploy.sh --fresh # rebuild & restart, wipe all data volumes first
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
TAG="${1:-gitea.doomlabs.de/kptltd00m/backerup-website:latest}"
|
FRESH=false
|
||||||
|
|
||||||
# 1. Build
|
for arg in "$@"; do
|
||||||
"$SCRIPT_DIR/build.sh" "$TAG"
|
case "$arg" in
|
||||||
|
--fresh) FRESH=true ;;
|
||||||
|
*) echo "Unknown argument: $arg" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# 2. Export the image tag so docker compose picks it up if you override it
|
# Stop and remove existing containers
|
||||||
export BACKERUP_IMAGE="$TAG"
|
echo "==> Stopping existing containers…"
|
||||||
|
|
||||||
# 3. Bring the stack up (recreate on image change)
|
|
||||||
echo "==> Starting stack via docker compose…"
|
|
||||||
docker compose \
|
docker compose \
|
||||||
--file "$SCRIPT_DIR/docker-compose.yml" \
|
--file "$SCRIPT_DIR/docker-compose.yml" \
|
||||||
up --detach --pull never --remove-orphans
|
down --remove-orphans 2>/dev/null || true
|
||||||
|
|
||||||
|
# Optionally wipe data volumes
|
||||||
|
if [ "$FRESH" = true ]; then
|
||||||
|
echo "==> --fresh: removing data volumes…"
|
||||||
|
docker compose \
|
||||||
|
--file "$SCRIPT_DIR/docker-compose.yml" \
|
||||||
|
down --volumes 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build image and bring the stack up
|
||||||
|
echo "==> Building and starting stack via docker compose…"
|
||||||
|
docker compose \
|
||||||
|
--file "$SCRIPT_DIR/docker-compose.yml" \
|
||||||
|
up --build --detach --remove-orphans
|
||||||
|
|
||||||
echo "==> Backerup website is running at http://localhost:3000"
|
echo "==> Backerup website is running at http://localhost:3000"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
backerup-website:
|
backerup-website:
|
||||||
image: gitea.doomlabs.de/kptltd00m/backerup-website:latest
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
image: backerup-website:latest
|
||||||
container_name: backerup-website
|
container_name: backerup-website
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -10,6 +13,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
|
- AUTH_SECRET=tSdfJ57w1IyCpkZijj4UEytTmqk+PWRr45R9BC077cw=
|
||||||
|
- ADMIN_USERNAME=admin
|
||||||
|
- ADMIN_PASSWORD=admin333
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backerup_data:
|
backerup_data:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getFile } from '@/lib/store'
|
import { getFile, deleteFile } from '@/lib/store'
|
||||||
|
import { getSession } from '@/lib/session'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
@@ -12,3 +13,21 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
return NextResponse.json(record)
|
return NextResponse.json(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ token: string }> },
|
||||||
|
) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await params
|
||||||
|
try {
|
||||||
|
await deleteFile(token)
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: (e as Error).message }, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { FileRecord } from "@/lib/store";
|
import type { FileRecord } from "@/lib/store";
|
||||||
|
|
||||||
@@ -54,20 +54,52 @@ function SortIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TrashIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BrowseClient({
|
export default function BrowseClient({
|
||||||
records: allRecords,
|
records: allRecords,
|
||||||
groups,
|
groups,
|
||||||
|
isAdmin = false,
|
||||||
}: {
|
}: {
|
||||||
records: FileRecord[];
|
records: FileRecord[];
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
isAdmin?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [records, setRecords] = useState<FileRecord[]>(allRecords);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||||
const [sort, setSort] = useState<SortOrder>("newest");
|
const [sort, setSort] = useState<SortOrder>("newest");
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async (token: string, name: string, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm(`Delete "${name}"?\n\nThis permanently removes the file and cannot be undone.`)) return;
|
||||||
|
setDeleting(token);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/files/${token}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
setRecords((prev) => prev.filter((r) => r.token !== token));
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({})) as { error?: string };
|
||||||
|
alert(data.error ?? `Delete failed (${res.status})`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert("Network error – could not delete file");
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let r = allRecords;
|
let r = records;
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
@@ -107,7 +139,7 @@ export default function BrowseClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [allRecords, search, selectedGroup, typeFilter, sort]);
|
}, [records, search, selectedGroup, typeFilter, sort]);
|
||||||
|
|
||||||
const activeFilters =
|
const activeFilters =
|
||||||
(search.trim() ? 1 : 0) + (selectedGroup ? 1 : 0) + (typeFilter !== "all" ? 1 : 0);
|
(search.trim() ? 1 : 0) + (selectedGroup ? 1 : 0) + (typeFilter !== "all" ? 1 : 0);
|
||||||
@@ -218,9 +250,9 @@ export default function BrowseClient({
|
|||||||
|
|
||||||
{/* ── Results count ── */}
|
{/* ── Results count ── */}
|
||||||
<p className="mb-5 text-xs text-slate-600">
|
<p className="mb-5 text-xs text-slate-600">
|
||||||
{filtered.length === allRecords.length
|
{filtered.length === records.length
|
||||||
? `${allRecords.length} file${allRecords.length !== 1 ? "s" : ""} shared by the community`
|
? `${records.length} file${records.length !== 1 ? "s" : ""} shared by the community`
|
||||||
: `${filtered.length} of ${allRecords.length} files`}
|
: `${filtered.length} of ${records.length} files`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Cards ── */}
|
{/* ── Cards ── */}
|
||||||
@@ -241,10 +273,10 @@ export default function BrowseClient({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{filtered.map((r) => (
|
{filtered.map((r) => (
|
||||||
|
<div key={r.token} className="group/card rounded-2xl border border-white/5 glass card-hover overflow-hidden">
|
||||||
<Link
|
<Link
|
||||||
key={r.token}
|
|
||||||
href={`/files/${r.token}`}
|
href={`/files/${r.token}`}
|
||||||
className="group rounded-2xl border border-white/5 glass p-5 card-hover block"
|
className="group block px-5 pt-5 pb-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -295,9 +327,10 @@ export default function BrowseClient({
|
|||||||
<ArrowIcon />
|
<ArrowIcon />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Meta row */}
|
{/* Card footer: meta row + admin delete inline */}
|
||||||
<div className="mt-4 flex items-center gap-3 text-xs text-slate-600">
|
<div className="flex items-center gap-3 px-5 pb-4 text-xs text-slate-600">
|
||||||
<span>{formatBytes(r.size)}</span>
|
<span>{formatBytes(r.size)}</span>
|
||||||
<span className="text-slate-800">·</span>
|
<span className="text-slate-800">·</span>
|
||||||
<span>{formatDate(r.uploadedAt)}</span>
|
<span>{formatDate(r.uploadedAt)}</span>
|
||||||
@@ -307,8 +340,21 @@ export default function BrowseClient({
|
|||||||
<span className="font-mono truncate">{r.containerName}</span>
|
<span className="font-mono truncate">{r.containerName}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDelete(r.token, r.displayName ?? r.containerName ?? r.originalName, e)}
|
||||||
|
disabled={deleting === r.token}
|
||||||
|
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-1 opacity-0 group-hover/card:opacity-100 transition-opacity hover:text-rose-400 hover:bg-rose-500/10 active:scale-95 disabled:opacity-40 text-slate-600"
|
||||||
|
title="Delete file"
|
||||||
|
aria-label="Delete file"
|
||||||
|
>
|
||||||
|
{deleting === r.token
|
||||||
|
? <span className="w-3 h-3 rounded-full border border-rose-400 border-t-transparent animate-spin" />
|
||||||
|
: <TrashIcon />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { readMeta, listGroups } from "@/lib/store";
|
import { readMeta, listGroups } from "@/lib/store";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
import BrowseClient from "./BrowseClient";
|
import BrowseClient from "./BrowseClient";
|
||||||
|
|
||||||
export default async function BrowsePage() {
|
export default async function BrowsePage() {
|
||||||
const [allRecords, groups] = await Promise.all([readMeta(), listGroups()]);
|
const [allRecords, groups, session] = await Promise.all([
|
||||||
|
readMeta(),
|
||||||
|
listGroups(),
|
||||||
|
getSession(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl px-6 py-16">
|
<div className="mx-auto max-w-5xl px-6 py-16">
|
||||||
@@ -31,7 +36,7 @@ export default async function BrowsePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client section: search + filter + cards */}
|
{/* Client section: search + filter + cards */}
|
||||||
<BrowseClient records={allRecords} groups={groups} />
|
<BrowseClient records={allRecords} groups={groups} isAdmin={session?.role === "admin"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function DeleteFileButton({ token, name }: { token: string; name: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm(`Delete "${name}"?\n\nThis permanently removes the file and cannot be undone.`)) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/files/${token}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
router.push("/browse");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({})) as { error?: string };
|
||||||
|
alert(data.error ?? `Delete failed (${res.status})`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert("Network error – could not delete file");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full items-center justify-center gap-2.5 rounded-xl border py-3 text-sm font-semibold transition-all disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgba(239,68,68,0.3)",
|
||||||
|
background: "rgba(239,68,68,0.06)",
|
||||||
|
color: "#fca5a5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="w-4 h-4 rounded-full border border-rose-400 border-t-transparent animate-spin" />
|
||||||
|
Deleting…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
Delete file
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getFile } from "@/lib/store";
|
import { getFile } from "@/lib/store";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
import CopyLinkButton from "./CopyLinkButton";
|
import CopyLinkButton from "./CopyLinkButton";
|
||||||
|
import DeleteFileButton from "./DeleteFileButton";
|
||||||
|
|
||||||
function formatBytes(n: number) {
|
function formatBytes(n: number) {
|
||||||
if (n < 1024) return `${n} B`;
|
if (n < 1024) return `${n} B`;
|
||||||
@@ -24,8 +26,9 @@ export default async function FileDetailPage({
|
|||||||
params: Promise<{ token: string }>;
|
params: Promise<{ token: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { token } = await params;
|
const { token } = await params;
|
||||||
const record = await getFile(token);
|
const [record, session] = await Promise.all([getFile(token), getSession()]);
|
||||||
if (!record) notFound();
|
if (!record) notFound();
|
||||||
|
const isAdmin = session?.role === "admin";
|
||||||
|
|
||||||
const isBackerup = record.type === "backerup";
|
const isBackerup = record.type === "backerup";
|
||||||
const downloadUrl = `/api/files/${token}/download`;
|
const downloadUrl = `/api/files/${token}/download`;
|
||||||
@@ -138,6 +141,10 @@ export default async function FileDetailPage({
|
|||||||
|
|
||||||
<CopyLinkButton token={token} />
|
<CopyLinkButton token={token} />
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<DeleteFileButton token={token} name={title} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/browse"
|
href="/browse"
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-white/8 bg-white/3 py-3 text-sm font-medium text-slate-400 hover:text-white hover:bg-white/6 transition-all"
|
className="flex w-full items-center justify-center gap-2 rounded-xl border border-white/8 bg-white/3 py-3 text-sm font-medium text-slate-400 hover:text-white hover:bg-white/6 transition-all"
|
||||||
|
|||||||
@@ -34,9 +34,8 @@ export default function LoginForm({ from }: Props) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Navigate to intended destination or browse
|
// Navigate to intended destination — fresh page load picks up the session cookie
|
||||||
router.push(from || '/browse')
|
router.push(from || '/browse')
|
||||||
router.refresh()
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error — please try again')
|
setError('Network error — please try again')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -92,3 +92,16 @@ export function getUploadPath(token: string, originalName: string): string {
|
|||||||
return path.join(UPLOADS_DIR, token, originalName)
|
return path.join(UPLOADS_DIR, token, originalName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(token: string): Promise<void> {
|
||||||
|
await ensureReady()
|
||||||
|
const record = await getFile(token)
|
||||||
|
if (!record) throw new Error('File not found')
|
||||||
|
|
||||||
|
// Remove from DB first
|
||||||
|
await getDb().execute({ sql: 'DELETE FROM files WHERE token = ?', args: [token] })
|
||||||
|
|
||||||
|
// Remove upload directory (best-effort — don't fail if already gone)
|
||||||
|
const dir = path.join(UPLOADS_DIR, token)
|
||||||
|
await fs.rm(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-6
@@ -152,20 +152,32 @@ export async function countUsers(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeds an initial admin user if no users exist yet.
|
* Ensures the admin user defined by ADMIN_USERNAME / ADMIN_PASSWORD env vars
|
||||||
* Reads ADMIN_USERNAME (default "admin") and ADMIN_PASSWORD from environment.
|
* exists and has the correct password. Runs on every login attempt so that
|
||||||
|
* changing the env var (and restarting the container) takes effect immediately.
|
||||||
|
* Does nothing when ADMIN_PASSWORD is not set.
|
||||||
*/
|
*/
|
||||||
export async function seedAdminIfEmpty(): Promise<void> {
|
export async function seedAdminIfEmpty(): Promise<void> {
|
||||||
const count = await countUsers()
|
|
||||||
if (count > 0) return
|
|
||||||
const password = process.env.ADMIN_PASSWORD
|
const password = process.env.ADMIN_PASSWORD
|
||||||
if (!password) return
|
if (!password) return
|
||||||
|
|
||||||
const username = process.env.ADMIN_USERNAME ?? 'admin'
|
const username = process.env.ADMIN_USERNAME ?? 'admin'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const existing = await findUserByUsername(username)
|
||||||
|
if (!existing) {
|
||||||
await createUser(username, password, 'admin')
|
await createUser(username, password, 'admin')
|
||||||
console.log(`[auth] Seeded initial admin user: "${username}"`)
|
console.log(`[auth] Created admin user: "${username}"`)
|
||||||
|
} else {
|
||||||
|
// Always sync the password so env-var changes take effect after restart
|
||||||
|
const matches = await verifyPassword(password, existing.passwordHash, existing.salt)
|
||||||
|
if (!matches) {
|
||||||
|
await updatePassword(existing.id, password)
|
||||||
|
console.log(`[auth] Synced password for admin user: "${username}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[auth] Failed to seed admin user:', e)
|
console.error('[auth] Failed to sync admin user:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user