From 71ff97f30225fda82d7fb2b9cbae1fb568d6d560 Mon Sep 17 00:00:00 2001 From: hwinkel Date: Fri, 13 Mar 2026 12:06:09 +0100 Subject: [PATCH] ADD: added inital scripts and password recovery scripts --- .react-router/types/+routes.ts | 12 +- .../app/routes/+types/settings.password.ts | 65 ++++++++ Dockerfile | 3 + README.md | 154 ++++++++++++++---- app/components/layout/topbar.tsx | 18 +- app/routes.ts | 1 + app/routes/settings.password.tsx | 136 ++++++++++++++++ docker-compose.yml | 12 +- package.json | 4 +- scripts/reset-password.ts | 65 ++++++++ scripts/setup-admin.ts | 92 +++++++++++ 11 files changed, 522 insertions(+), 40 deletions(-) create mode 100644 .react-router/types/app/routes/+types/settings.password.ts create mode 100644 app/routes/settings.password.tsx create mode 100644 scripts/reset-password.ts create mode 100644 scripts/setup-admin.ts diff --git a/.react-router/types/+routes.ts b/.react-router/types/+routes.ts index f8015d5..3b34c78 100644 --- a/.react-router/types/+routes.ts +++ b/.react-router/types/+routes.ts @@ -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"); diff --git a/.react-router/types/app/routes/+types/settings.password.ts b/.react-router/types/app/routes/+types/settings.password.ts new file mode 100644 index 0000000..7d9e4d4 --- /dev/null +++ b/.react-router/types/app/routes/+types/settings.password.ts @@ -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; + +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"]; +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ae81adb..9933750 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index de9b88b..67273ed 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/components/layout/topbar.tsx b/app/components/layout/topbar.tsx index c44e2ab..ab8c9c2 100644 --- a/app/components/layout/topbar.tsx +++ b/app/components/layout/topbar.tsx @@ -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)} + + +
+
+ + )} + + ); +} diff --git a/docker-compose.yml b/docker-compose.yml index e3f86a0..f4d4dfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json index a33dcb2..6329cc2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/reset-password.ts b/scripts/reset-password.ts new file mode 100644 index 0000000..a48f302 --- /dev/null +++ b/scripts/reset-password.ts @@ -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 --password "); + 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()); diff --git a/scripts/setup-admin.ts b/scripts/setup-admin.ts new file mode 100644 index 0000000..d52336f --- /dev/null +++ b/scripts/setup-admin.ts @@ -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());