ADD: added inital scripts and password recovery scripts
This commit is contained in:
@@ -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"];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user