ADD: added inital scripts and password recovery scripts
This commit is contained in:
@@ -65,6 +65,9 @@ type Pages = {
|
|||||||
"/archiv": {
|
"/archiv": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
|
"/settings/password": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
"/admin/users": {
|
"/admin/users": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
@@ -126,7 +129,7 @@ type Pages = {
|
|||||||
type RouteFiles = {
|
type RouteFiles = {
|
||||||
"root.tsx": {
|
"root.tsx": {
|
||||||
id: "root";
|
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": {
|
"routes/login.tsx": {
|
||||||
id: "routes/login";
|
id: "routes/login";
|
||||||
@@ -138,7 +141,7 @@ type RouteFiles = {
|
|||||||
};
|
};
|
||||||
"routes/dashboard-layout.tsx": {
|
"routes/dashboard-layout.tsx": {
|
||||||
id: "routes/dashboard-layout";
|
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": {
|
"routes/home.tsx": {
|
||||||
id: "routes/home";
|
id: "routes/home";
|
||||||
@@ -184,6 +187,10 @@ type RouteFiles = {
|
|||||||
id: "routes/archiv";
|
id: "routes/archiv";
|
||||||
page: "/archiv";
|
page: "/archiv";
|
||||||
};
|
};
|
||||||
|
"routes/settings.password.tsx": {
|
||||||
|
id: "routes/settings.password";
|
||||||
|
page: "/settings/password";
|
||||||
|
};
|
||||||
"routes/admin-layout.tsx": {
|
"routes/admin-layout.tsx": {
|
||||||
id: "routes/admin-layout";
|
id: "routes/admin-layout";
|
||||||
page: "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs";
|
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.invoices.$invoiceId": typeof import("./app/routes/companies.$id.invoices.$invoiceId.tsx");
|
||||||
"routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx");
|
"routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx");
|
||||||
"routes/archiv": typeof import("./app/routes/archiv.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-layout": typeof import("./app/routes/admin-layout.tsx");
|
||||||
"routes/admin.users": typeof import("./app/routes/admin.users.tsx");
|
"routes/admin.users": typeof import("./app/routes/admin.users.tsx");
|
||||||
"routes/admin.users.new": typeof import("./app/routes/admin.users.new.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 . .
|
COPY . .
|
||||||
RUN npm run build
|
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 ----
|
# ---- Production Stage ----
|
||||||
FROM node:alpine AS runner
|
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/node_modules/.prisma ./node_modules/.prisma
|
||||||
COPY --from=builder /app/build ./build
|
COPY --from=builder /app/build ./build
|
||||||
|
COPY --from=builder /app/scripts-dist ./scripts
|
||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
# Annas Rechnungsmanager
|
# Annas Rechnungsmanager
|
||||||
|
|
||||||
Buchhaltungs- und Rechnungsverwaltungssystem für Mandanten.
|
Buchhaltungs- und Rechnungsverwaltungssystem für Steuerberater und Buchhalter mit Mandantenverwaltung.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Mandantenverwaltung** — Mehrere Unternehmen verwalten
|
- **Mandantenverwaltung** — Mehrere Unternehmen verwalten, archivieren und wiederherstellen
|
||||||
- **Rechnungen** — Erstellen, verwalten, als PDF exportieren (§14 UStG konform)
|
- **Rechnungen** — Erstellen, versenden, bezahlen, als PDF exportieren (§14 UStG konform)
|
||||||
- **Kundenverwaltung** — Kundenstammdaten pro Mandant
|
- **Kundenverwaltung** — Kundenstammdaten pro Mandant
|
||||||
- **Steuerberichte** — USt-Voranmeldung, monatliche & quartalsweise Auswertungen
|
- **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
|
## Tech Stack
|
||||||
|
|
||||||
@@ -16,7 +20,9 @@ Buchhaltungs- und Rechnungsverwaltungssystem für Mandanten.
|
|||||||
- **Cookie-Session-Auth** (bcryptjs)
|
- **Cookie-Session-Auth** (bcryptjs)
|
||||||
- **Tailwind CSS v4** + shadcn/ui
|
- **Tailwind CSS v4** + shadcn/ui
|
||||||
- **@react-pdf/renderer** für PDF-Generierung
|
- **@react-pdf/renderer** für PDF-Generierung
|
||||||
- **Docker** für die Datenbank
|
- **Docker** für Datenbank und Deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Lokale Entwicklung
|
## Lokale Entwicklung
|
||||||
|
|
||||||
@@ -37,83 +43,161 @@ npm install
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="mysql://annas_user:annas_password@localhost:3306/annas_rechnungen"
|
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
|
### Datenbank einrichten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:migrate # Migrationen ausführen
|
npx prisma migrate deploy # Migrationen ausführen
|
||||||
npm run db:seed # Demo-Daten einspielen
|
npm run db:seed # Demo-Daten einspielen (optional)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Demo-Zugangsdaten:** `anna@example.de` / `demo123`
|
### Dev-Server starten
|
||||||
|
|
||||||
### Entwicklungsserver starten
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
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
|
## 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
|
```bash
|
||||||
docker compose up -d
|
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
|
```bash
|
||||||
# Secret-Werte in k8s.yml anpassen (auth-secret, ggf. Passwörter), dann:
|
docker build -t annasrechnungsmanager:latest .
|
||||||
kubectl apply -f k8s.yml
|
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
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
components/ UI-Komponenten (ui/, layout/, company/, invoice/)
|
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)
|
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
|
types/ Gemeinsame TypeScript-Typen
|
||||||
db/
|
scripts/
|
||||||
docker-compose.yml MariaDB + phpMyAdmin für lokale Entwicklung
|
setup-admin.ts Admin-Benutzer anlegen / Passwort setzen
|
||||||
|
reset-password.ts Recovery: Passwort eines Benutzers zurücksetzen
|
||||||
prisma/
|
prisma/
|
||||||
schema.prisma Datenbankschema
|
schema.prisma Datenbankschema
|
||||||
migrations/ Migrationsverlauf
|
migrations/ Migrationsverlauf
|
||||||
|
seed.ts Demo-Daten
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rechnungs-Compliance (§14 UStG)
|
## Rechnungs-Compliance (§14 UStG)
|
||||||
|
|
||||||
Alle PDFs enthalten die gesetzlich vorgeschriebenen Pflichtangaben:
|
Alle PDFs enthalten die gesetzlich vorgeschriebenen Pflichtangaben:
|
||||||
|
|
||||||
- Name & Anschrift Rechnungssteller und -empfänger
|
- Name & Anschrift Rechnungssteller und -empfänger
|
||||||
- Steuernummer / USt-IdNr. des Ausstellers
|
- Steuernummer / USt-IdNr. des Ausstellers
|
||||||
- Rechnungsdatum & Rechnungsnummer (fortlaufend)
|
- Rechnungsdatum & fortlaufende Rechnungsnummer
|
||||||
- Leistungsdatum / Lieferdatum
|
- Leistungsdatum / Lieferdatum
|
||||||
- Leistungsbeschreibung
|
- Leistungsbeschreibung
|
||||||
- Nettobetrag, USt-Satz, USt-Betrag, Bruttobetrag
|
- Nettobetrag, USt-Satz, USt-Betrag, Bruttobetrag
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMatches, useLocation, Link, Form } from "react-router";
|
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 {
|
interface Breadcrumb {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -174,6 +174,22 @@ export function Topbar({
|
|||||||
>
|
>
|
||||||
{getInitials(userName)}
|
{getInitials(userName)}
|
||||||
</div>
|
</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">
|
<Form method="post" action="/logout">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default [
|
|||||||
route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
|
route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
|
||||||
route("companies/:id/reports", "routes/companies.$id.reports.tsx"),
|
route("companies/:id/reports", "routes/companies.$id.reports.tsx"),
|
||||||
route("archiv", "routes/archiv.tsx"),
|
route("archiv", "routes/archiv.tsx"),
|
||||||
|
route("settings/password", "routes/settings.password.tsx"),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Admin routes
|
// 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
|
DATABASE_URL: mysql://annas_user:annas_password@mariadb:3306/annas_rechnungen
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
NODE_ENV: production
|
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:
|
depends_on:
|
||||||
mariadb:
|
mariadb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- app_network
|
- app_network
|
||||||
command: >
|
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:
|
volumes:
|
||||||
mariadb_data:
|
mariadb_data:
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,9 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
|
"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": {
|
"prisma": {
|
||||||
"seed": "npx ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
"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