ADD: added inital scripts and password recovery scripts

This commit is contained in:
hwinkel
2026-03-13 12:06:09 +01:00
parent 3a2a94ec19
commit 71ff97f302
11 changed files with 522 additions and 40 deletions
+10 -2
View File
@@ -65,6 +65,9 @@ type Pages = {
"/archiv": {
params: {};
};
"/settings/password": {
params: {};
};
"/admin/users": {
params: {};
};
@@ -126,7 +129,7 @@ type Pages = {
type RouteFiles = {
"root.tsx": {
id: "root";
page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/archiv" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports";
page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/archiv" | "/settings/password" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports";
};
"routes/login.tsx": {
id: "routes/login";
@@ -138,7 +141,7 @@ type RouteFiles = {
};
"routes/dashboard-layout.tsx": {
id: "routes/dashboard-layout";
page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/archiv";
page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/archiv" | "/settings/password";
};
"routes/home.tsx": {
id: "routes/home";
@@ -184,6 +187,10 @@ type RouteFiles = {
id: "routes/archiv";
page: "/archiv";
};
"routes/settings.password.tsx": {
id: "routes/settings.password";
page: "/settings/password";
};
"routes/admin-layout.tsx": {
id: "routes/admin-layout";
page: "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs";
@@ -262,6 +269,7 @@ type RouteModules = {
"routes/companies.$id.invoices.$invoiceId": typeof import("./app/routes/companies.$id.invoices.$invoiceId.tsx");
"routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx");
"routes/archiv": typeof import("./app/routes/archiv.tsx");
"routes/settings.password": typeof import("./app/routes/settings.password.tsx");
"routes/admin-layout": typeof import("./app/routes/admin-layout.tsx");
"routes/admin.users": typeof import("./app/routes/admin.users.tsx");
"routes/admin.users.new": typeof import("./app/routes/admin.users.new.tsx");
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../settings.password.js")
type Info = GetInfo<{
file: "routes/settings.password.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/settings.password";
module: typeof import("../settings.password.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
+3
View File
@@ -13,6 +13,8 @@ RUN npx prisma generate
COPY . .
RUN npm run build
# Compile recovery scripts to plain JS for use inside the container
RUN npx tsc --module commonjs --target es2020 --moduleResolution node --esModuleInterop true --outDir scripts-dist scripts/setup-admin.ts scripts/reset-password.ts
# ---- Production Stage ----
FROM node:alpine AS runner
@@ -28,6 +30,7 @@ RUN npm ci --omit=dev
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/build ./build
COPY --from=builder /app/scripts-dist ./scripts
COPY prisma ./prisma
EXPOSE 3000
+119 -35
View File
@@ -1,13 +1,17 @@
# Annas Rechnungsmanager
Buchhaltungs- und Rechnungsverwaltungssystem für Mandanten.
Buchhaltungs- und Rechnungsverwaltungssystem für Steuerberater und Buchhalter mit Mandantenverwaltung.
## Features
- **Mandantenverwaltung** — Mehrere Unternehmen verwalten
- **Rechnungen** — Erstellen, verwalten, als PDF exportieren (§14 UStG konform)
- **Mandantenverwaltung** — Mehrere Unternehmen verwalten, archivieren und wiederherstellen
- **Rechnungen** — Erstellen, versenden, bezahlen, als PDF exportieren (§14 UStG konform)
- **Kundenverwaltung** — Kundenstammdaten pro Mandant
- **Steuerberichte** — USt-Voranmeldung, monatliche & quartalsweise Auswertungen
- **Benutzerverwaltung** — Mehrere Benutzer mit Rollen (USER / ADMIN)
- **Audit-Log** — Protokollierung aller relevanten Aktionen mit Benutzer und IP
- **Archiv** — Archivierte Mandanten mit vollständiger Historie einsehbar
- **Papierkorb** — Gelöschte Rechnungen wiederherstellbar
## Tech Stack
@@ -16,7 +20,9 @@ Buchhaltungs- und Rechnungsverwaltungssystem für Mandanten.
- **Cookie-Session-Auth** (bcryptjs)
- **Tailwind CSS v4** + shadcn/ui
- **@react-pdf/renderer** für PDF-Generierung
- **Docker** für die Datenbank
- **Docker** für Datenbank und Deployment
---
## Lokale Entwicklung
@@ -37,83 +43,161 @@ npm install
```env
DATABASE_URL="mysql://annas_user:annas_password@localhost:3306/annas_rechnungen"
AUTH_SECRET="dein-zufaelliger-secret-string"
AUTH_SECRET="dein-zufaelliger-geheimer-string"
```
### Datenbank einrichten
```bash
npm run db:migrate # Migrationen ausführen
npm run db:seed # Demo-Daten einspielen
npx prisma migrate deploy # Migrationen ausführen
npm run db:seed # Demo-Daten einspielen (optional)
```
**Demo-Zugangsdaten:** `anna@example.de` / `demo123`
### Entwicklungsserver starten
### Dev-Server starten
```bash
npm run dev
```
Startet automatisch MariaDB via Docker und den Vite-Dev-Server auf `http://localhost:5173`.
Startet den Vite-Dev-Server auf `http://localhost:5173`.
## Scripts
| Befehl | Beschreibung |
|---|---|
| `npm run dev` | DB starten + Dev-Server |
| `npm run build` | Produktions-Build |
| `npm run start` | Produktions-Server starten |
| `npm run typecheck` | TypeScript prüfen |
| `npm run db:migrate` | Prisma Migrationen ausführen |
| `npm run db:seed` | Demo-Daten einspielen |
| `npm run db:studio` | Prisma Studio öffnen |
---
## Deployment
### Docker Compose
### Erstes Deployment
Startet App + MariaDB als komplettes Setup:
**1. Image bauen:**
```bash
docker build -t annasrechnungsmanager:latest .
```
**2. Secrets setzen** (Shell-Exports oder `.env`-Datei):
```bash
export AUTH_SECRET="langer-zufaelliger-string"
export ADMIN_PASSWORD="sicheres-startpasswort" # nur beim ersten Start
```
**3. Stack starten:**
```bash
docker compose up -d
```
Die App läuft auf `http://localhost:3000`. Migrationen werden automatisch beim Start ausgeführt.
Beim ersten Start mit gesetztem `ADMIN_PASSWORD`:
- Prisma-Migrationen werden automatisch ausgeführt
- Admin-Benutzer (`username: admin`) wird angelegt oder aktualisiert
> `AUTH_SECRET` muss als Umgebungsvariable gesetzt sein.
**4. `ADMIN_PASSWORD` entfernen** (nach erfolgreichem Login und eigenem Passwort setzen).
### Kubernetes
> Die App läuft auf `http://localhost:3000`.
### Folge-Deployments
```bash
# Secret-Werte in k8s.yml anpassen (auth-secret, ggf. Passwörter), dann:
kubectl apply -f k8s.yml
docker build -t annasrechnungsmanager:latest .
docker compose up -d --no-deps app
```
Das Manifest (`k8s.yml`) enthält: Namespace, Secret, MariaDB (PVC + Deployment + Service), App (Deployment mit Init-Container für Migrationen + Service + Ingress).
Migrationen werden beim Start automatisch angewendet. `ADMIN_PASSWORD` ist nicht erneut nötig.
---
## Admin-Benutzer & Recovery
### Admin-Passwort setzen (im laufenden Container)
```bash
docker exec -e ADMIN_PASSWORD=neuespasswort annas_app node scripts/setup-admin.js
```
Erstellt oder aktualisiert den Benutzer `admin` (idempotent). Mindestens 8 Zeichen.
### Passwort eines beliebigen Benutzers zurücksetzen
```bash
docker exec -it annas_app node scripts/reset-password.js --username anna --password neuespasswort
```
Zeigt alle vorhandenen Benutzer an, wenn der angegebene Username nicht existiert.
### Passwort im laufenden Betrieb ändern
Jeder eingeloggte Benutzer kann sein Passwort über das **Schlüssel-Icon** in der Topbar ändern (`/settings/password`).
---
## Benutzerverwaltung (Admin)
Erreichbar über den **Admin**-Button in der Topbar (nur für Benutzer mit Rolle `ADMIN`).
- Benutzer anlegen, bearbeiten, löschen
- Rollen vergeben: `USER` oder `ADMIN`
- Audit-Log einsehen (Aktion, Benutzer, IP-Adresse, Zeitstempel)
Login ist möglich mit **Benutzername** oder **E-Mail-Adresse**.
---
## Scripts
| Befehl | Beschreibung |
|---|---|
| `npm run dev` | Dev-Server starten |
| `npm run build` | Produktions-Build |
| `npm run start` | Produktions-Server starten |
| `npm run typecheck` | TypeScript prüfen |
| `npm run db:migrate` | Prisma Migrationen ausführen (Dev) |
| `npm run db:seed` | Demo-Daten einspielen |
| `npm run db:studio` | Prisma Studio öffnen |
| `npm run setup-admin` | Admin-Benutzer anlegen / Passwort setzen |
| `npm run reset-password` | Passwort eines Benutzers zurücksetzen |
**Beispiele (Entwicklung):**
```bash
# Admin-Passwort setzen
ADMIN_PASSWORD=geheim npm run setup-admin
# Passwort zurücksetzen
npm run reset-password -- --username anna --password neuespasswort
```
---
## Projektstruktur
```
app/
components/ UI-Komponenten (ui/, layout/, company/, invoice/)
lib/ Hilfsfunktionen (prisma, tax, utils, invoice-number)
lib/ Hilfsfunktionen (prisma, tax, utils, invoice-number, logger)
routes/ Route-Dateien (React Router v7, file-based)
session.server.ts Auth (Login, Logout, Session)
admin.* Admin-Bereich (Benutzerverwaltung, Audit-Log)
api.* REST-API-Routen (resource routes)
settings.* Benutzereinstellungen (Passwort ändern)
archiv.tsx Archiv-Übersicht (archivierte Mandanten)
session.server.ts Auth (Login via Username/E-Mail, Logout, Session)
types/ Gemeinsame TypeScript-Typen
db/
docker-compose.yml MariaDB + phpMyAdmin für lokale Entwicklung
scripts/
setup-admin.ts Admin-Benutzer anlegen / Passwort setzen
reset-password.ts Recovery: Passwort eines Benutzers zurücksetzen
prisma/
schema.prisma Datenbankschema
migrations/ Migrationsverlauf
seed.ts Demo-Daten
```
---
## Rechnungs-Compliance (§14 UStG)
Alle PDFs enthalten die gesetzlich vorgeschriebenen Pflichtangaben:
- Name & Anschrift Rechnungssteller und -empfänger
- Steuernummer / USt-IdNr. des Ausstellers
- Rechnungsdatum & Rechnungsnummer (fortlaufend)
- Rechnungsdatum & fortlaufende Rechnungsnummer
- Leistungsdatum / Lieferdatum
- Leistungsbeschreibung
- Nettobetrag, USt-Satz, USt-Betrag, Bruttobetrag
+17 -1
View File
@@ -1,5 +1,5 @@
import { useMatches, useLocation, Link, Form } from "react-router";
import { ChevronRight, LayoutDashboard, LogOut, Shield, Archive } from "lucide-react";
import { ChevronRight, LayoutDashboard, LogOut, Shield, Archive, KeyRound } from "lucide-react";
interface Breadcrumb {
label: string;
@@ -174,6 +174,22 @@ export function Topbar({
>
{getInitials(userName)}
</div>
<Link
to="/settings/password"
title="Passwort ändern"
style={{
display: "flex",
alignItems: "center",
background: "none",
border: "none",
cursor: "pointer",
color: "#94a3b8",
padding: "0.25rem",
textDecoration: "none",
}}
>
<KeyRound style={{ width: "1rem", height: "1rem" }} />
</Link>
<Form method="post" action="/logout">
<button
type="submit"
+1
View File
@@ -16,6 +16,7 @@ export default [
route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
route("companies/:id/reports", "routes/companies.$id.reports.tsx"),
route("archiv", "routes/archiv.tsx"),
route("settings/password", "routes/settings.password.tsx"),
]),
// Admin routes
+136
View File
@@ -0,0 +1,136 @@
import { Form, useActionData, useNavigation } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { log } from "@/lib/logger";
import { Button } from "@/components/ui/button";
import { KeyRound, CheckCircle2 } from "lucide-react";
export const handle = {
breadcrumbs: () => [{ label: "Passwort ändern" }],
};
export async function loader({ request }: { request: Request }) {
await requireUser(request);
return null;
}
export async function action({ request }: { request: Request }) {
const user = await requireUser(request);
const form = await request.formData();
const current = form.get("current") as string;
const next = form.get("next") as string;
const confirm = form.get("confirm") as string;
if (!current || !next || !confirm) {
return { error: "Alle Felder sind erforderlich." };
}
if (next.length < 8) {
return { error: "Das neue Passwort muss mindestens 8 Zeichen lang sein." };
}
if (next !== confirm) {
return { error: "Die neuen Passwörter stimmen nicht überein." };
}
const dbUser = await prisma.user.findUnique({ where: { id: user.id } });
if (!dbUser) return { error: "Benutzer nicht gefunden." };
const valid = await bcrypt.compare(current, dbUser.passwordHash);
if (!valid) {
return { error: "Das aktuelle Passwort ist falsch." };
}
const passwordHash = await bcrypt.hash(next, 12);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
await log({ userId: user.id, action: "CHANGE_PASSWORD", entity: "User", entityId: user.id, request });
return { success: true };
}
export default function ChangePasswordPage() {
const result = useActionData<typeof action>();
const navigation = useNavigation();
const submitting = navigation.state === "submitting";
return (
<div className="max-w-md mx-auto animate-fade-in">
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="p-2.5 rounded-xl bg-indigo-50">
<KeyRound className="h-5 w-5 text-indigo-600" />
</div>
<h1 className="text-2xl font-bold text-slate-900">Passwort ändern</h1>
</div>
<p className="text-slate-500 text-sm">Geben Sie Ihr aktuelles und ein neues Passwort ein.</p>
</div>
{result?.success ? (
<div className="bg-emerald-50 border border-emerald-200 rounded-2xl p-6 flex items-center gap-3 text-emerald-800">
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
<p className="font-medium">Passwort erfolgreich geändert.</p>
</div>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
{result?.error && (
<div className="mb-5 px-4 py-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
{result.error}
</div>
)}
<Form method="post" className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5" htmlFor="current">
Aktuelles Passwort
</label>
<input
id="current"
name="current"
type="password"
autoComplete="current-password"
required
className="w-full rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5" htmlFor="next">
Neues Passwort
</label>
<input
id="next"
name="next"
type="password"
autoComplete="new-password"
required
minLength={8}
className="w-full rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 transition"
/>
<p className="text-xs text-slate-400 mt-1">Mindestens 8 Zeichen.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5" htmlFor="confirm">
Neues Passwort bestätigen
</label>
<input
id="confirm"
name="confirm"
type="password"
autoComplete="new-password"
required
className="w-full rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 transition"
/>
</div>
<Button type="submit" disabled={submitting} className="w-full mt-2">
{submitting ? "Wird gespeichert…" : "Passwort ändern"}
</Button>
</Form>
</div>
)}
</div>
);
}
+11 -1
View File
@@ -29,13 +29,23 @@ services:
DATABASE_URL: mysql://annas_user:annas_password@mariadb:3306/annas_rechnungen
AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production
# Beim ersten Start: Admin-Benutzer (username: admin) anlegen oder Passwort setzen.
# Danach kann diese Variable entfernt werden.
ADMIN_PASSWORD: ${ADMIN_PASSWORD:admin}
depends_on:
mariadb:
condition: service_healthy
networks:
- app_network
command: >
sh -c "npx prisma migrate deploy && npm run start"
sh -c "
npx prisma migrate deploy &&
if [ -n \"$ADMIN_PASSWORD\" ]; then
echo 'ADMIN_PASSWORD gesetzt Admin-Benutzer wird eingerichtet...' &&
node scripts/setup-admin.js;
fi &&
npm run start
"
volumes:
mariadb_data:
+3 -1
View File
@@ -12,7 +12,9 @@
"lint": "eslint",
"db:migrate": "prisma migrate dev",
"db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"setup-admin": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' scripts/setup-admin.ts",
"reset-password": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' scripts/reset-password.ts"
},
"prisma": {
"seed": "npx ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
+65
View File
@@ -0,0 +1,65 @@
/**
* reset-password.ts
*
* Emergency recovery script resets the password for any user.
* Run directly inside the container via docker exec.
*
* Usage:
* docker exec -it annas_app node scripts/reset-password.js --username admin --password newpassword
*
* During development:
* npx ts-node --compiler-options '{"module":"CommonJS"}' scripts/reset-password.ts --username admin --password newpassword
*/
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
function parseArgs(): { username: string; password: string } {
const args = process.argv.slice(2);
const get = (flag: string) => {
const i = args.indexOf(flag);
return i !== -1 ? args[i + 1] : undefined;
};
const username = get("--username");
const password = get("--password");
if (!username || !password) {
console.error("Usage: reset-password --username <username> --password <newpassword>");
process.exit(1);
}
return { username, password };
}
async function main() {
const { username, password } = parseArgs();
if (password.length < 8) {
console.error("ERROR: Password must be at least 8 characters.");
process.exit(1);
}
const user = await prisma.user.findFirst({ where: { username } });
if (!user) {
console.error(`ERROR: No user found with username "${username}".`);
const all = await prisma.user.findMany({ select: { username: true, email: true, role: true } });
console.error("Available users:");
all.forEach((u) => console.error(` - ${u.username} (${u.email}) [${u.role}]`));
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
console.log(`✅ Password reset for user "${username}" (${user.email}).`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+92
View File
@@ -0,0 +1,92 @@
/**
* setup-admin.ts
*
* Ensures the initial admin user (username: "admin") exists.
* Called automatically on every container start.
*
* Behaviour:
* - ADMIN_PASSWORD set → create or update admin with that password
* - ADMIN_PASSWORD not set, admin exists → nothing to do, skip silently
* - ADMIN_PASSWORD not set, admin missing → generate a random password,
* create the user, print to logs
*
* Manual usage:
* ADMIN_PASSWORD=secret npx ts-node --compiler-options '{"module":"CommonJS"}' scripts/setup-admin.ts
* docker exec -e ADMIN_PASSWORD=secret annas_app node scripts/setup-admin.js
*/
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
import { randomBytes } from "crypto";
const prisma = new PrismaClient();
function generatePassword(length = 16): string {
// URL-safe characters only so the password is easy to copy from logs
return randomBytes(Math.ceil((length * 3) / 4))
.toString("base64url")
.slice(0, length);
}
async function main() {
const explicitPassword = process.env.ADMIN_PASSWORD ?? process.argv[2];
const existing = await prisma.user.findUnique({ where: { username: "admin" } });
// Admin exists and no explicit password override → nothing to do
if (existing && !explicitPassword) {
console.log("[setup-admin] Admin user already exists skipping.");
return;
}
let password: string;
let generated = false;
if (explicitPassword) {
if (explicitPassword.length < 8) {
console.error("[setup-admin] ERROR: ADMIN_PASSWORD must be at least 8 characters.");
process.exit(1);
}
password = explicitPassword;
} else {
// No admin user yet and no password given → auto-generate
password = generatePassword(16);
generated = true;
}
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.upsert({
where: { username: "admin" },
update: { passwordHash, role: "ADMIN" },
create: {
username: "admin",
email: "admin@localhost",
name: "Administrator",
passwordHash,
role: "ADMIN",
},
});
if (generated) {
console.log("");
console.log("╔══════════════════════════════════════════════════╗");
console.log("║ ADMIN-ZUGANGSDATEN (einmalig) ║");
console.log("╠══════════════════════════════════════════════════╣");
console.log(`║ Benutzername : admin ║`);
console.log(`║ Passwort : ${password.padEnd(32)}`);
console.log("╠══════════════════════════════════════════════════╣");
console.log("║ Bitte sofort nach dem ersten Login ändern! ║");
console.log("╚══════════════════════════════════════════════════╝");
console.log("");
} else {
console.log(`[setup-admin] ✅ Admin user ${existing ? "updated" : "created"} (username: admin).`);
}
}
main()
.catch((e) => {
console.error("[setup-admin] FATAL:", e);
process.exit(1);
})
.finally(() => prisma.$disconnect());