Compare commits
55
Commits
dd0b88b6eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47945f6f64 | ||
|
|
d492182b4e | ||
|
|
72ec15c2ab | ||
|
|
cd7591fd65 | ||
|
|
e2c17aea18 | ||
|
|
7166c51680 | ||
|
|
133905a0fb | ||
|
|
036d177037 | ||
|
|
9fcb6ebab6 | ||
|
|
3ccf3bfcfa | ||
|
|
76f3d93873 | ||
|
|
52effad4a5 | ||
|
|
e06916de22 | ||
|
|
fdc348f06c | ||
|
|
793bb8c366 | ||
|
|
32a8b01845 | ||
|
|
590310ca95 | ||
|
|
89adb63f5e | ||
|
|
aa075762b2 | ||
|
|
27669adc21 | ||
|
|
7e458ae698 | ||
|
|
4b6a1946b4 | ||
|
|
6b5d5eefd6 | ||
|
|
b7deb5c852 | ||
|
|
b3a36514d1 | ||
|
|
fcf68626f8 | ||
|
|
db8e365932 | ||
|
|
985137b802 | ||
|
|
4179eee26d | ||
|
|
3dae506a6c | ||
|
|
a1c861202a | ||
|
|
ece8626137 | ||
|
|
576a85cd50 | ||
|
|
39b3636333 | ||
|
|
ec5e6b1629 | ||
|
|
00a3a89263 | ||
|
|
d97dd955d1 | ||
|
|
17e0cfe8a3 | ||
|
|
617f00b715 | ||
|
|
970986fef7 | ||
|
|
7a13dad693 | ||
|
|
3f90c33c5f | ||
|
|
17ceefe32a | ||
|
|
e7185103f5 | ||
|
|
f2d4241a1c | ||
|
|
2251209935 | ||
|
|
b5d3df6d7c | ||
|
|
f5b1593e46 | ||
|
|
f0bdf185d5 | ||
|
|
5871606ae6 | ||
|
|
c632751a7e | ||
|
|
25599a5516 | ||
|
|
7d94fe53ad | ||
|
|
ada029d500 | ||
|
|
246ddac9e7 |
No files matched your search
Regular → Executable
File mode changed.
@@ -1,86 +0,0 @@
|
||||
# AOS — Agency Operating System Environment (dev)
|
||||
|
||||
# --- Database ---
|
||||
DATABASE_URL=file:/home/z/my-project/db/custom.db
|
||||
|
||||
# --- Encryption (AES-256-GCM for secrets at rest) ---
|
||||
ENCRYPTION_KEY=UrEE757ABMzqcME/AQfef9XDPSaBlgIhVDOy8BIpbSY=
|
||||
|
||||
# --- Auth ---
|
||||
AUTH_SECRET=zB7wxxn7xDnClPUlwoKG4VwHDaIqvtAz3DW3Bn43GlM=
|
||||
|
||||
# --- Leads Finder Providers ---
|
||||
TAVILY_API_KEY=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
|
||||
FIRECRAWL_API_KEY=fc-2ddabe84274a44b39da31a49d1624700
|
||||
SERPAPI_API_KEY=5e1d045575cc29b705c53e8303b6ada506e8a3ef382fca78a2964c7e25751ba1
|
||||
APIFY_API_TOKEN=apify_api_YUsr1w7L0qFpvzwDirjv1oWHToT2xu3lhJt7
|
||||
|
||||
# --- App ---
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# ── Stripe ──
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_51TWfTxDGcQIOgPxeHVZh5ilWEc61bK7MBuBez4fGV5XrjRGwRXsXJp5rm1eAsWHGzQ20NBXRG7Nmp0pJRJfUflImA003OoDaSQt
|
||||
STRIPE_SECRET_KEY=sk_test_51TWfTxDGcQIOgPxej8AvcfLMt6a7IEgZk3L34WLnOCNw2AAAyqORQtgJLjEaj3Ygm014ZAo3oByvDSdJVFwYLHmk0048mdLZH4
|
||||
RESTRICTED_KEY=rk_test_51TWfTxDGcQIOgPxeGPSfA6ske0A6xPde23jfOJKbUfFOrEWl4bTIHv4MSOBwlQpravXk4mxKAjKdmkGb3asZqOVJ0048mdLZH4
|
||||
|
||||
# ── License Server (B2B SaaS) ──
|
||||
LICENSE_PRICE_STARTER_CENTS=2900
|
||||
LICENSE_PRICE_PRO_CENTS=5000
|
||||
LICENSE_PRICE_ENTERPRISE_CENTS=15000
|
||||
LICENSE_TRIAL_DAYS=14
|
||||
LICENSE_NOTICE_PERIOD_DAYS=90
|
||||
LICENSE_YEARLY_DISCOUNT_PCT=10
|
||||
LICENSE_OFFLINE_GRACE_HOURS=72
|
||||
|
||||
# ── Agency / Reseller ──
|
||||
AGENCY_DISCOUNT_MIN_PCT=0
|
||||
AGENCY_DISCOUNT_MAX_PCT=20
|
||||
AGENCY_DISCOUNT_DEFAULT_PCT=10
|
||||
|
||||
# ── Platform Domain ──
|
||||
# The domain AOS runs on. Tenant instances get subdomains of this domain.
|
||||
PLATFORM_DOMAIN=dfmpanel.eu
|
||||
# Public URL of the AOS instance (used by license-server SDK + email links)
|
||||
AOS_PUBLIC_URL=https://dfmpanel.eu
|
||||
|
||||
# ── Dokploy (tenant instance provisioning) ──
|
||||
# Dokploy server URL + API key for managing tenant instances.
|
||||
DOKPLOY_URL=https://cp.project-url.eu
|
||||
DOKPLOY_API_KEY=CEIqJlWVtmSNgffNWywTJmEeuvUJBndinBzudretwiuzVWPxuvUNmoQaHUwRwUBy
|
||||
# The Dokploy server ID to deploy to (default "default" if not set)
|
||||
# DOKPLOY_SERVER_ID=default
|
||||
# The Dokploy project to group tenant applications under
|
||||
# DOKPLOY_PROJECT_ID=default
|
||||
# The IP of the Dokploy server (used for Hetzner DNS A records)
|
||||
# DOKPLOY_SERVER_IP=123.456.789.012
|
||||
|
||||
# ── Hetzner Cloud DNS ──
|
||||
# DNS is managed via the Hetzner Cloud API (api.hetzner.cloud/v1/zones).
|
||||
# The legacy dns.hetzner.com API was shut down in May 2026.
|
||||
HETZNER_DNS_TOKEN=JhZMOBTMeWvXz91KVPxRh0U4UMFqkSNCMHJKiQS9kUjHIwCkWCc8ViZTm64MyaOp
|
||||
# Zone ID of dfmpanel.eu (found via: GET https://api.hetzner.cloud/v1/zones)
|
||||
HETZNER_ZONE_ID=1669672
|
||||
# If true, a single *.dfmpanel.eu wildcard A record handles all subdomains.
|
||||
# If false, a per-instance A record is created/deleted with each instance.
|
||||
USE_WILDCARD_DNS=false
|
||||
|
||||
# ── Dokploy Server ──
|
||||
# IP of the Dokploy server (A record target for instance subdomains).
|
||||
# Found from the existing @ and www A records of dfmpanel.eu.
|
||||
DOKPLOY_SERVER_IP=92.206.22.59
|
||||
|
||||
# ── Cron Job ──
|
||||
# Shared secret for the cleanup cron job (POST /api/aos/instances/cleanup)
|
||||
# The cron job (Dokploy cron or GitHub Action) must send this as:
|
||||
# Authorization: Bearer <CRON_SECRET>
|
||||
# CRON_SECRET=your-cron-secret
|
||||
|
||||
# ── Instance Defaults ──
|
||||
# Default Git repo + branch for new tenant instances
|
||||
INSTANCE_GIT_REPO=https://github.com/cetinmustafa83/restov1
|
||||
INSTANCE_GIT_BRANCH=main
|
||||
# Demo instance duration in days
|
||||
INSTANCE_DEMO_DAYS=14
|
||||
# Grace period after demo expiry or subscription cancellation (days)
|
||||
INSTANCE_GRACE_DAYS=7
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
name: AOS Cleanup Cron
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 03:00 UTC (05:00 Berlin time)
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger instance cleanup
|
||||
run: |
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
"${{ secrets.AOS_URL || 'https://dfmpanel.eu' }}/api/aos/instances/cleanup" \
|
||||
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--max-time 300)
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
echo "HTTP $HTTP_CODE"
|
||||
echo "Response: $BODY"
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::warning::Cleanup returned HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Cleanup completed successfully"
|
||||
@@ -59,3 +59,6 @@ aos-verify*.sh
|
||||
.dev.pid
|
||||
.zscripts/dev.pid
|
||||
db/backups/
|
||||
.env
|
||||
tool-results/
|
||||
upload/
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1114
|
||||
1089
|
||||
+15
-6
@@ -3,23 +3,28 @@
|
||||
# Multi-stage build: install → build → run
|
||||
|
||||
# --- Stage 1: Install dependencies ---
|
||||
FROM node:22-slim AS deps
|
||||
FROM node:24-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# --- Stage 2: Build ---
|
||||
FROM node:22-slim AS builder
|
||||
FROM node:24-slim AS builder
|
||||
WORKDIR /app
|
||||
# Cache bust — forces rebuild of all subsequent layers on every push
|
||||
ARG CACHE_BUST=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV DATABASE_URL="file:/app/db/custom.db"
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/*
|
||||
RUN npx prisma generate
|
||||
# Clean any stale .next cache before building
|
||||
RUN rm -rf .next
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 3: Runner ---
|
||||
FROM node:22-slim AS runner
|
||||
FROM node:24-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -29,9 +34,12 @@ ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Install only what's needed for production
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
sqlite3 curl && \
|
||||
sqlite3 curl openssl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pin prisma CLI into the image so CMD's db push doesn't npx-download prisma on every start
|
||||
RUN npm i -g prisma@6.19.2
|
||||
|
||||
# Copy built app
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
@@ -49,5 +57,6 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Run migrations + start server
|
||||
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node server.js"]
|
||||
# Run migrations + start server. NODE_ENV is forced here because the deploy
|
||||
# platform injects NODE_ENV=development, which breaks the production server.
|
||||
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && NODE_ENV=production node server.js"]
|
||||
@@ -43,6 +43,8 @@
|
||||
"@reactuses/core": "^6.0.5",
|
||||
"@tanstack/react-query": "^5.82.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -57,7 +59,10 @@
|
||||
"next-auth": "^4.24.11",
|
||||
"next-intl": "^4.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^10.2.1",
|
||||
"otplib": "^13.5.0",
|
||||
"prisma": "^6.11.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -68,6 +73,7 @@
|
||||
"recharts": "^2.15.4",
|
||||
"sharp": "^0.34.3",
|
||||
"sonner": "^2.0.6",
|
||||
"stripe": "^23.0.1",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -428,6 +434,8 @@
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.3", "https://registry.npmjs.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmjs.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmjs.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -438,6 +446,18 @@
|
||||
|
||||
"@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "https://registry.npmjs.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
|
||||
|
||||
"@otplib/core": ["@otplib/core@13.5.0", "", {}, "sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ=="],
|
||||
|
||||
"@otplib/hotp": ["@otplib/hotp@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw=="],
|
||||
|
||||
"@otplib/plugin-base32-scure": ["@otplib/plugin-base32-scure@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@scure/base": "^2.2.0" } }, "sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA=="],
|
||||
|
||||
"@otplib/plugin-crypto-noble": ["@otplib/plugin-crypto-noble@13.5.0", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@otplib/core": "13.5.0" } }, "sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA=="],
|
||||
|
||||
"@otplib/totp": ["@otplib/totp@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/hotp": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg=="],
|
||||
|
||||
"@otplib/uri": ["@otplib/uri@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0" } }, "sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "https://registry.npmjs.com/@panva/hkdf/-/hkdf-1.2.1.tgz", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.4", "https://registry.npmjs.com/@parcel/watcher/-/watcher-2.5.4.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.4", "@parcel/watcher-darwin-arm64": "2.5.4", "@parcel/watcher-darwin-x64": "2.5.4", "@parcel/watcher-freebsd-x64": "2.5.4", "@parcel/watcher-linux-arm-glibc": "2.5.4", "@parcel/watcher-linux-arm-musl": "2.5.4", "@parcel/watcher-linux-arm64-glibc": "2.5.4", "@parcel/watcher-linux-arm64-musl": "2.5.4", "@parcel/watcher-linux-x64-glibc": "2.5.4", "@parcel/watcher-linux-x64-musl": "2.5.4", "@parcel/watcher-win32-arm64": "2.5.4", "@parcel/watcher-win32-ia32": "2.5.4", "@parcel/watcher-win32-x64": "2.5.4" } }, "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ=="],
|
||||
@@ -610,6 +630,8 @@
|
||||
|
||||
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "https://registry.npmjs.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
|
||||
|
||||
"@scure/base": ["@scure/base@2.4.0", "", {}, "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmjs.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
@@ -720,6 +742,10 @@
|
||||
|
||||
"@types/node": ["@types/node@25.0.9", "https://registry.npmjs.com/@types/node/-/node-25.0.9.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||
|
||||
"@types/nodemailer": ["@types/nodemailer@8.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="],
|
||||
|
||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.8", "https://registry.npmjs.com/@types/react/-/react-19.2.8.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmjs.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
@@ -796,6 +822,8 @@
|
||||
|
||||
"anser": ["anser@2.3.5", "https://registry.npmjs.com/anser/-/anser-2.3.5.tgz", {}, "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmjs.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "https://registry.npmjs.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -860,6 +888,8 @@
|
||||
|
||||
"callsites": ["callsites@3.1.0", "https://registry.npmjs.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001764", "https://registry.npmjs.com/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "https://registry.npmjs.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
@@ -886,6 +916,8 @@
|
||||
|
||||
"client-only": ["client-only@0.0.1", "https://registry.npmjs.com/client-only/-/client-only-0.0.1.tgz", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmjs.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cm6-theme-basic-light": ["cm6-theme-basic-light@0.2.0", "https://registry.npmjs.com/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA=="],
|
||||
@@ -956,6 +988,8 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmjs.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "https://registry.npmjs.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmjs.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
@@ -984,6 +1018,8 @@
|
||||
|
||||
"diff": ["diff@5.2.0", "https://registry.npmjs.com/diff/-/diff-5.2.0.tgz", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "https://registry.npmjs.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmjs.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
@@ -1132,6 +1168,8 @@
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmjs.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmjs.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "https://registry.npmjs.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
@@ -1242,6 +1280,8 @@
|
||||
|
||||
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "https://registry.npmjs.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "https://registry.npmjs.com/is-generator-function/-/is-generator-function-1.1.2.tgz", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "https://registry.npmjs.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -1520,6 +1560,8 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "https://registry.npmjs.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"nodemailer": ["nodemailer@10.2.1", "", {}, "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w=="],
|
||||
|
||||
"nypm": ["nypm@0.6.2", "https://registry.npmjs.com/nypm/-/nypm-0.6.2.tgz", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
||||
|
||||
"oauth": ["oauth@0.9.15", "https://registry.npmjs.com/oauth/-/oauth-0.9.15.tgz", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="],
|
||||
@@ -1552,6 +1594,8 @@
|
||||
|
||||
"optionator": ["optionator@0.9.4", "https://registry.npmjs.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"otplib": ["otplib@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/hotp": "13.5.0", "@otplib/plugin-base32-scure": "13.5.0", "@otplib/plugin-crypto-noble": "13.5.0", "@otplib/totp": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ=="],
|
||||
|
||||
"outvariant": ["outvariant@1.4.0", "https://registry.npmjs.com/outvariant/-/outvariant-1.4.0.tgz", {}, "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "https://registry.npmjs.com/own-keys/-/own-keys-1.0.1.tgz", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
@@ -1560,6 +1604,8 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "https://registry.npmjs.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "https://registry.npmjs.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "https://registry.npmjs.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
@@ -1586,6 +1632,8 @@
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "https://registry.npmjs.com/pkg-types/-/pkg-types-2.3.0.tgz", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"po-parser": ["po-parser@2.1.1", "https://registry.npmjs.com/po-parser/-/po-parser-2.1.1.tgz", {}, "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmjs.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
@@ -1614,6 +1662,8 @@
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "https://registry.npmjs.com/pure-rand/-/pure-rand-6.1.0.tgz", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmjs.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
@@ -1668,6 +1718,10 @@
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "https://registry.npmjs.com/remark-rehype/-/remark-rehype-11.1.2.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "https://registry.npmjs.com/resolve/-/resolve-1.22.11.tgz", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "https://registry.npmjs.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
@@ -1696,6 +1750,8 @@
|
||||
|
||||
"semver": ["semver@7.7.3", "https://registry.npmjs.com/semver/-/semver-7.7.3.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "https://registry.npmjs.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "https://registry.npmjs.com/set-function-name/-/set-function-name-2.0.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
@@ -1738,6 +1794,8 @@
|
||||
|
||||
"strict-event-emitter": ["strict-event-emitter@0.4.6", "https://registry.npmjs.com/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", {}, "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "https://registry.npmjs.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||
|
||||
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "https://registry.npmjs.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
||||
@@ -1752,10 +1810,14 @@
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmjs.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "https://registry.npmjs.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmjs.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"stripe": ["stripe@23.0.1", "", { "peerDependencies": { "@types/node": ">=20" }, "optionalPeers": ["@types/node"] }, "sha512-VFLs9IAzSZ8GIm6/vqhjqOL0fbYrSNYCpgZ3LS3YkSi/i5RnyMpu/Hf9YxPTOCYwc3mZKU3IJvuvKffQH2NLMg=="],
|
||||
|
||||
"style-mod": ["style-mod@4.1.3", "https://registry.npmjs.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "https://registry.npmjs.com/style-to-js/-/style-to-js-1.1.21.tgz", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
@@ -1872,14 +1934,24 @@
|
||||
|
||||
"which-collection": ["which-collection@1.0.2", "https://registry.npmjs.com/which-collection/-/which-collection-1.0.2.tgz", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "https://registry.npmjs.com/which-typed-array/-/which-typed-array-1.1.20.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "https://registry.npmjs.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "https://registry.npmjs.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"yjs": ["yjs@13.6.29", "https://registry.npmjs.com/yjs/-/yjs-13.6.29.tgz", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmjs.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
@@ -2002,8 +2074,12 @@
|
||||
|
||||
"refractor/prismjs": ["prismjs@1.27.0", "https://registry.npmjs.com/prismjs/-/prismjs-1.27.0.tgz", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
|
||||
|
||||
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
|
||||
|
||||
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
@@ -2026,6 +2102,12 @@
|
||||
|
||||
"refractor/parse-entities/is-hexadecimal": ["is-hexadecimal@1.0.4", "https://registry.npmjs.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
|
||||
|
||||
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"refractor/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@1.0.4", "https://registry.npmjs.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
# Pinned toolchain for deterministic builds (railpack/mise reads this).
|
||||
# node 24 = LTS, proven locally; node 26 broke /_global-error prerender.
|
||||
[tools]
|
||||
node = "24"
|
||||
bun = "1.3.14"
|
||||
+11
-1
@@ -2,10 +2,14 @@
|
||||
"name": "nextjs_tailwind_shadcn_ts",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "24.x",
|
||||
"bun": "1.3.x"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"dev:log": "next dev -p 3000 2>&1 | tee dev.log",
|
||||
"build": "prisma generate && next build --webpack && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
||||
"build": "prisma generate && next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
||||
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
|
||||
"lint": "eslint .",
|
||||
"db:push": "prisma db push --accept-data-loss",
|
||||
@@ -53,6 +57,8 @@
|
||||
"@reactuses/core": "^6.0.5",
|
||||
"@tanstack/react-query": "^5.82.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -67,7 +73,10 @@
|
||||
"next-auth": "^4.24.11",
|
||||
"next-intl": "^4.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^10.2.1",
|
||||
"otplib": "^13.5.0",
|
||||
"prisma": "^6.11.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -78,6 +87,7 @@
|
||||
"recharts": "^2.15.4",
|
||||
"sharp": "^0.34.3",
|
||||
"sonner": "^2.0.6",
|
||||
"stripe": "^23.0.1",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.1.0",
|
||||
|
||||
+83
-1
@@ -19,9 +19,50 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client
|
||||
role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client|member|reseller
|
||||
// --- Account → Company link ---
|
||||
// For reseller/client accounts: the Company this user manages. Null for
|
||||
// the platform owner + internal staff. Set at registration.
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id])
|
||||
// --- Authentication ---
|
||||
// scrypt-based password hash (format: "salt:hash", both hex). Null = seeded
|
||||
// user that hasn't claimed their account via registration yet.
|
||||
passwordHash String?
|
||||
emailVerifiedAt DateTime?
|
||||
lastLoginAt DateTime?
|
||||
// --- Two-factor authentication (opt-in per user) ---
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
// 'email' = OTP sent by mail | 'totp' = Google Authenticator / Authy / 1Password
|
||||
twoFactorMethod String? // email|totp
|
||||
// Base32 TOTP secret (stored plaintext for now; encrypt-at-rest later).
|
||||
// Only set when method='totp'.
|
||||
twoFactorSecret String?
|
||||
// Backup codes (hashed, newline-separated) for recovery. Generated on enable.
|
||||
twoFactorBackup String?
|
||||
twoFactorEnabledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
otpCodes OtpCode[]
|
||||
}
|
||||
|
||||
// One-time passcodes (email OTP method). Each login attempt issues a code,
|
||||
// stored hashed (never plaintext). Codes expire after 5 minutes and are
|
||||
// single-use (consumedAt set on successful verify).
|
||||
model OtpCode {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
// SHA-256 hash of the 6-digit code (we never store the code itself).
|
||||
codeHash String
|
||||
purpose String @default("login") // login|email-verify|password-reset
|
||||
attempts Int @default(0)
|
||||
consumedAt DateTime?
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, purpose])
|
||||
}
|
||||
|
||||
// Module registry — installed/enabled/disabled state of each domain module
|
||||
@@ -106,6 +147,8 @@ model Company {
|
||||
tickets Ticket[]
|
||||
agencyApplication AgencyApplication?
|
||||
instances Instance[]
|
||||
repoAssignments RepoAssignment[]
|
||||
users User[]
|
||||
}
|
||||
|
||||
model Person {
|
||||
@@ -953,6 +996,8 @@ model Instance {
|
||||
metadata String? // JSON: { gitRepo, gitBranch, imageTag, resources }
|
||||
lastError String?
|
||||
lastErrorAt DateTime?
|
||||
allowedRepoId String?
|
||||
allowedRepo AllowedRepo? @relation(fields: [allowedRepoId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events InstanceEvent[]
|
||||
@@ -984,3 +1029,40 @@ model ProvisioningJob {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allowed Repos (admin controls which Gitea repos clients can deploy)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin selects which repos are available for instance provisioning.
|
||||
// Repos can be:
|
||||
// - public: visible to all clients in the "Neue Instanz" dialog
|
||||
// - assigned: only visible to specific companies (per-company assignment)
|
||||
// Unfinished/custom projects are NOT shared unless explicitly assigned.
|
||||
model AllowedRepo {
|
||||
id String @id @default(cuid())
|
||||
giteaOwner String // e.g. "dfmsolutions" (Gitea org/user)
|
||||
giteaRepo String // e.g. "restov1" (repo name)
|
||||
displayName String // human-readable: "Pizzeria RESTO (restov1)"
|
||||
description String? // what this product does
|
||||
product String // product slug: "restov1", "aos-cms", etc.
|
||||
isPublic Boolean @default(false) // true = all clients can see it
|
||||
isActive Boolean @default(true) // false = hidden from all
|
||||
envTemplate String? // JSON: default env vars for this repo
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
assignments RepoAssignment[]
|
||||
instances Instance[] // instances created from this repo
|
||||
|
||||
@@unique([giteaOwner, giteaRepo])
|
||||
}
|
||||
|
||||
model RepoAssignment {
|
||||
id String @id @default(cuid())
|
||||
allowedRepoId String
|
||||
allowedRepo AllowedRepo @relation(fields: [allowedRepoId], references: [id], onDelete: Cascade)
|
||||
companyId String // which company can use this repo
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([allowedRepoId, companyId])
|
||||
}
|
||||
Regular → Executable
+10
-4
@@ -72,16 +72,22 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
},
|
||||
})
|
||||
|
||||
// TODO (production): send email to company.email with subject
|
||||
// "Ihre Reseller-Bewerbung wurde genehmigt — {discountPct}% Rabatt"
|
||||
// containing the discount and a link to the agency portal.
|
||||
// Send approval email
|
||||
const { sendAgencyApprovedEmail } = await import('@/lib/email')
|
||||
if (application.company.email) {
|
||||
await sendAgencyApprovedEmail({
|
||||
to: application.company.email,
|
||||
companyName: application.company.name,
|
||||
discountPct,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: 'approved',
|
||||
discountPct,
|
||||
email: application.company.email,
|
||||
message: `Genehmigt mit ${discountPct}% Rabatt. E-Mail an ${application.company.email ?? '(keine E-Mail hinterlegt)'} würde versendet.`,
|
||||
message: `Genehmigt mit ${discountPct}% Rabatt.${application.company.email ? ` E-Mail an ${application.company.email} versendet.` : ' Keine E-Mail hinterlegt.'}`,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
|
||||
Regular → Executable
+10
@@ -60,6 +60,16 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
},
|
||||
})
|
||||
|
||||
// Send rejection email
|
||||
const { sendAgencyRejectedEmail } = await import('@/lib/email')
|
||||
if (application.company.email) {
|
||||
await sendAgencyRejectedEmail({
|
||||
to: application.company.email,
|
||||
companyName: application.company.name,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, status: 'rejected', reason })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -1,103 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { storeFile, detectContentType } from '@/lib/storage'
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 30
|
||||
|
||||
// POST /api/aos/belege/upload
|
||||
// Upload a receipt file with content-addressed storage (PRD §5.5 + §5.7 Belege).
|
||||
// Accepts multipart/form-data with file + metadata.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const rateLimited = checkRateLimit(req, 'public')
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const type = formData.get('type') as string | null
|
||||
const vendor = formData.get('vendor') as string | null
|
||||
const invoiceNumber = formData.get('invoiceNumber') as string | null
|
||||
const amountCentsStr = formData.get('amountCents') as string | null
|
||||
const bookingDateStr = formData.get('bookingDate') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'file required (multipart/form-data, field name: file)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
// Detect content type from magic bytes
|
||||
const detectedType = detectContentType(buffer)
|
||||
const fileName = file.name
|
||||
const fileType = file.type || detectedType
|
||||
const fileSizeBytes = buffer.length
|
||||
|
||||
// Store file (content-addressed, dedup, atomic write)
|
||||
const stored = await storeFile(buffer, fileType)
|
||||
|
||||
// Check for duplicate (same hash = same file already registered as Beleg)
|
||||
const existing = await db.beleg.findUnique({ where: { fileHash: stored.hash } })
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
beleg: existing,
|
||||
duplicate: true,
|
||||
message: 'File already registered (same SHA-256 hash)',
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
// Create Beleg record
|
||||
const count = await db.beleg.count()
|
||||
const number = `BEL-2026-${String(count + 1).padStart(4, '0')}`
|
||||
const belegType = type || 'sonstiges'
|
||||
|
||||
// GoBD retention: 8 years for invoices, 6 for business letters
|
||||
const retentionUntil = new Date()
|
||||
retentionUntil.setFullYear(retentionUntil.getFullYear() + (belegType.includes('rechnung') ? 8 : 6))
|
||||
|
||||
const beleg = await db.beleg.create({
|
||||
data: {
|
||||
number,
|
||||
type: belegType,
|
||||
fileHash: stored.hash,
|
||||
fileName,
|
||||
fileType,
|
||||
fileSizeBytes,
|
||||
vendor: vendor ?? null,
|
||||
invoiceNumber: invoiceNumber ?? null,
|
||||
amountCents: amountCentsStr ? parseInt(amountCentsStr) : null,
|
||||
bookingDate: bookingDateStr ? new Date(bookingDateStr) : null,
|
||||
retentionUntil,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'nora@nordlicht.digital',
|
||||
action: 'beleg.upload',
|
||||
entity: 'Beleg',
|
||||
entityId: beleg.id,
|
||||
after: JSON.stringify({ number, type: belegType, fileName, hash: stored.hash.slice(0, 16) + '…', sizeBytes: fileSizeBytes }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
beleg,
|
||||
storage: {
|
||||
hash: stored.hash,
|
||||
sizeBytes: stored.size,
|
||||
contentType: stored.contentType,
|
||||
detectedType,
|
||||
deduped: stored.size !== fileSizeBytes, // if size differs, existing file was returned
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
@@ -1,8 +1,28 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Allowed fields for an inline update. All optional — only the keys
|
||||
// actually present in the body are touched. This makes the endpoint
|
||||
// safe for partial edits (e.g. only changing the email).
|
||||
const updateCompanySchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
legalForm: z.string().max(50).nullable().optional(),
|
||||
vatId: z.string().max(50).nullable().optional(),
|
||||
registerCourt: z.string().max(100).nullable().optional(),
|
||||
registerNumber: z.string().max(100).nullable().optional(),
|
||||
website: z.string().max(200).nullable().optional(),
|
||||
email: z.string().max(200).nullable().optional(),
|
||||
phone: z.string().max(100).nullable().optional(),
|
||||
city: z.string().max(100).nullable().optional(),
|
||||
zip: z.string().max(20).nullable().optional(),
|
||||
street: z.string().max(200).nullable().optional(),
|
||||
source: z.string().max(100).nullable().optional(),
|
||||
tags: z.string().max(500).nullable().optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/crm/companies/[id] — full company detail with timeline
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
@@ -56,3 +76,153 @@ export async function GET(
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/aos/crm/companies/[id] — update company master data
|
||||
// Audit-logged (GoBD). Agency-only fields (isAgency, agencyStatus,
|
||||
// parentAgencyId, agencyDiscountPct) are intentionally NOT editable here —
|
||||
// those go through /api/aos/agency/[id]/approve|reject.
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateCompanySchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const existing = await db.company.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Only write keys that are actually present — avoids clobbering
|
||||
// fields the UI didn't submit.
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
data[k] = v
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.company.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'crm.company:update',
|
||||
entity: 'Company',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ company: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/crm/companies/[id] — delete company.
|
||||
//
|
||||
// GoBD cascade policy:
|
||||
// - Non-accounting relations (deals, projects, tickets, leads, persons,
|
||||
// instances, agency clients) are CASCADE-DELETED. These are operational
|
||||
// records, not accounting — hard delete is allowed per the GoBD rule.
|
||||
// - Accounting relations (invoices, licenses, expenses, contracts) BLOCK
|
||||
// deletion. These are GoBD-retained and may not be deleted. The call
|
||||
// returns 409 with the accounting dependency counts so the user knows
|
||||
// which records to storno/cancel first.
|
||||
//
|
||||
// Audit-logged. The cascade is wrapped in a transaction so a partial failure
|
||||
// doesn't leave orphan records.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.company.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check ACCOUNTING relations — these block deletion (GoBD).
|
||||
const [invoices, licenses, expenses, contracts] = await Promise.all([
|
||||
db.invoice.count({ where: { companyId: id } }),
|
||||
db.license.count({ where: { companyId: id } }),
|
||||
db.expense.count({ where: { companyId: id } }),
|
||||
db.contract.count({ where: { companyId: id } }),
|
||||
])
|
||||
|
||||
const accountingDeps: Record<string, number> = {
|
||||
invoices,
|
||||
licenses,
|
||||
expenses,
|
||||
contracts,
|
||||
}
|
||||
const totalAccounting = Object.values(accountingDeps).reduce((s, n) => s + n, 0)
|
||||
if (totalAccounting > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Firma hat GoBD-relevante Datensätze (Rechnungen, Lizenzen, Ausgaben, Verträge) — diese können nicht gelöscht, nur storniert werden.',
|
||||
dependencies: accountingDeps,
|
||||
total: totalAccounting,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// No accounting deps — cascade-delete non-accounting relations + company.
|
||||
await db.$transaction(async (tx) => {
|
||||
// Cascade non-accounting records (these have no GoBD retention).
|
||||
await tx.lead.deleteMany({ where: { companyId: id } })
|
||||
await tx.person.deleteMany({ where: { companyId: id } })
|
||||
await tx.ticket.deleteMany({ where: { companyId: id } })
|
||||
const projectIds = (await tx.project.findMany({ where: { companyId: id }, select: { id: true } })).map((p) => p.id)
|
||||
if (projectIds.length > 0) {
|
||||
await tx.task.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
await tx.timeEntry.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||
try { await tx.portalApproval.deleteMany({ where: { projectId: { in: projectIds } } }) } catch {}
|
||||
}
|
||||
await tx.project.deleteMany({ where: { companyId: id } })
|
||||
await tx.deal.deleteMany({ where: { companyId: id } })
|
||||
await tx.instance.deleteMany({ where: { companyId: id } })
|
||||
// Agency application + repo assignments (if this company applied/is a reseller).
|
||||
try { await tx.agencyApplication.deleteMany({ where: { companyId: id } }) } catch {}
|
||||
try { await tx.repoAssignment.deleteMany({ where: { companyId: id } }) } catch {}
|
||||
// Unlink agency clients (set parentAgencyId = null so they become direct clients).
|
||||
await tx.company.updateMany({ where: { parentAgencyId: id }, data: { parentAgencyId: null } })
|
||||
// Unlink users (set companyId = null so they keep their accounts).
|
||||
await tx.user.updateMany({ where: { companyId: id }, data: { companyId: null } })
|
||||
// Finally delete the company itself.
|
||||
await tx.company.delete({ where: { id } })
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'crm.company:delete',
|
||||
entity: 'Company',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify({ deleted: true, cascaded: 'deals,projects,tickets,leads,persons,instances' }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, id })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createCompanySchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
legalForm: z.string().max(50).nullable().optional(),
|
||||
vatId: z.string().max(50).nullable().optional(),
|
||||
registerCourt: z.string().max(100).nullable().optional(),
|
||||
registerNumber: z.string().max(100).nullable().optional(),
|
||||
website: z.string().max(200).nullable().optional(),
|
||||
email: z.string().max(200).nullable().optional(),
|
||||
phone: z.string().max(100).nullable().optional(),
|
||||
city: z.string().max(100).nullable().optional(),
|
||||
zip: z.string().max(20).nullable().optional(),
|
||||
street: z.string().max(200).nullable().optional(),
|
||||
source: z.string().max(100).optional().default('manual'),
|
||||
tags: z.string().max(500).nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/crm/companies — create a new CRM company.
|
||||
// Audit-logged (GoBD). The UI's "Neue Firma" button posts here.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createCompanySchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// Trim website (strip protocol — schema stores bare host).
|
||||
let website = body.website ?? null
|
||||
if (website) {
|
||||
website = website.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const company = await db.company.create({
|
||||
data: {
|
||||
name: body.name.trim(),
|
||||
legalForm: body.legalForm ?? null,
|
||||
vatId: body.vatId ?? null,
|
||||
registerCourt: body.registerCourt ?? null,
|
||||
registerNumber: body.registerNumber ?? null,
|
||||
website,
|
||||
email: body.email ?? null,
|
||||
phone: body.phone ?? null,
|
||||
city: body.city ?? null,
|
||||
zip: body.zip ?? null,
|
||||
street: body.street ?? null,
|
||||
source: body.source,
|
||||
tags: body.tags ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'crm.company:create',
|
||||
entity: 'Company',
|
||||
entityId: company.id,
|
||||
after: JSON.stringify({ name: company.name, source: company.source }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ company }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateDealSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
valueCents: z.number().int().min(0).optional(),
|
||||
stage: z.enum(['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']).optional(),
|
||||
probability: z.number().int().min(0).max(100).optional(),
|
||||
expectedClose: z.string().nullable().optional(),
|
||||
lossReason: z.string().max(500).nullable().optional(),
|
||||
ownerId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/crm/deals/[id] — update a deal in the pipeline.
|
||||
// Audit-logged (GoBD). Used by the deal edit dialog (title, value, stage,
|
||||
// probability, expected close, loss reason).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateDealSchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const existing = await db.deal.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'deal not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (k === 'expectedClose' && typeof v === 'string') {
|
||||
data[k] = new Date(v)
|
||||
} else {
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.deal.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'crm.deal:update',
|
||||
entity: 'Deal',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ deal: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/crm/deals/[id] — delete a deal.
|
||||
// Deals have no downstream FKs (no invoice/contract references in the
|
||||
// schema), so this is a hard delete. Audit-logged for GoBD.
|
||||
// If a deal has been converted into a contract or invoice, the UI should
|
||||
// block the deletion — the contract/invoice still references the company,
|
||||
// not the deal, so we don't enforce it here at the schema level.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.deal.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'deal not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.deal.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'crm.deal:delete',
|
||||
entity: 'Deal',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify({ deleted: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, id })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateExpenseSchema = z.object({
|
||||
categoryId: z.string().max(50).optional(),
|
||||
vendor: z.string().max(200).nullable().optional(),
|
||||
description: z.string().min(1).max(8000).optional(),
|
||||
amountCents: z.number().int().min(0).optional(),
|
||||
taxBp: z.number().int().min(0).max(10000).optional(),
|
||||
grossCents: z.number().int().min(0).optional(),
|
||||
billable: z.boolean().optional(),
|
||||
markupPct: z.number().int().min(0).max(500).optional(),
|
||||
recurring: z.boolean().optional(),
|
||||
recurringInterval: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
|
||||
status: z.enum(['recorded', 'approved', 'invoiced', 'rejected']).optional(),
|
||||
companyId: z.string().nullable().optional(),
|
||||
projectId: z.string().nullable().optional(),
|
||||
date: z.string().optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/expenses/[id] — update an expense.
|
||||
// Recomputes grossCents from amountCents + taxBp when amount or tax changes.
|
||||
// Audit-logged (GoBD).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateExpenseSchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const existing = await db.expense.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'expense not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Block edits once the expense has been exported to DATEV (GoBD lock).
|
||||
if (existing.datevExported) {
|
||||
return NextResponse.json(
|
||||
{ error: 'expense is DATEV-exported and locked (GoBD)' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (k === 'date' && typeof v === 'string') {
|
||||
data[k] = new Date(v)
|
||||
} else {
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Recompute grossCents if amount or tax changed.
|
||||
const newAmount = typeof data.amountCents === 'number' ? data.amountCents : existing.amountCents
|
||||
const newTaxBp = typeof data.taxBp === 'number' ? data.taxBp : existing.taxBp
|
||||
if (
|
||||
typeof data.amountCents === 'number' ||
|
||||
typeof data.taxBp === 'number'
|
||||
) {
|
||||
data.grossCents = Math.round(newAmount * (1 + newTaxBp / 10000))
|
||||
}
|
||||
|
||||
const updated = await db.expense.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'expense:update',
|
||||
entity: 'Expense',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ expense: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/expenses/[id] — DISABLED (GoBD).
|
||||
//
|
||||
// Expenses are accounting records. Per GoBD they may be STORNO'd (reversed)
|
||||
// but never deleted. Use the dedicated storno flow at
|
||||
// PATCH /api/aos/expenses/[id]/storno instead — it sets status='rejected',
|
||||
// records a storno reason, and writes an AuditLog entry. The expense row is
|
||||
// retained indefinitely. DATEV-exported expenses are fully locked.
|
||||
export async function DELETE() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Ausgaben können nicht gelöscht werden (GoBD). Bitte über „Storno“ stornieren.',
|
||||
useInstead: 'PATCH /api/aos/expenses/[id]/storno',
|
||||
},
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const stornoSchema = z.object({
|
||||
reason: z.string().min(1).max(500),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/expenses/[id]/storno — Storno (GoBD reversal).
|
||||
//
|
||||
// Expenses are accounting records: they may be STORNO'd (reversed) but never
|
||||
// deleted. Storno sets status='rejected' and records the reason + timestamp.
|
||||
// The original expense row is retained indefinitely for GoBD retention.
|
||||
//
|
||||
// Guard rails:
|
||||
// - DATEV-exported expenses are fully locked (immutable, § 146 AO). 409.
|
||||
// - Expenses already converted to an invoice cannot be storno'd directly —
|
||||
// the invoice must be storno'd instead (the booking flows from there). 409.
|
||||
// - Already-rejected expenses return 409 (idempotent: no double-storno).
|
||||
//
|
||||
// Audit: writes an AuditLog entry with action='expense.storno' capturing
|
||||
// before/after + reason. This is the durable record of the reversal.
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = stornoSchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Storno-Grund erforderlich', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { reason } = parsed.data
|
||||
|
||||
const existing = await db.expense.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'expense not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// GoBD lock: DATEV-exported records are immutable.
|
||||
if (existing.datevExported) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ausgabe ist DATEV-exportiert und gesperrt (GoBD § 146 AO) — Storno nicht möglich.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// If already invoiced, the booking lives on the invoice — storno the invoice.
|
||||
if (existing.status === 'invoiced' || existing.convertedInvoiceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ausgabe bereits in eine Rechnung umgewandelt — bitte die Rechnung stornieren.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Idempotent: already storno'd.
|
||||
if (existing.status === 'rejected') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ausgabe ist bereits storniert.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const updated = await db.expense.update({
|
||||
where: { id },
|
||||
data: { status: 'rejected' },
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'expense.storno',
|
||||
entity: 'Expense',
|
||||
entityId: id,
|
||||
before: JSON.stringify({
|
||||
status: existing.status,
|
||||
description: existing.description,
|
||||
amountCents: existing.amountCents,
|
||||
vendor: existing.vendor,
|
||||
}),
|
||||
after: JSON.stringify({
|
||||
status: 'rejected',
|
||||
stornoReason: reason,
|
||||
stornoAt: now,
|
||||
}),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
expense: updated,
|
||||
stornoReason: reason,
|
||||
stornoAt: now,
|
||||
message: 'Ausgabe storniert (GoBD). Datensatz bleibt für Aufbewahrung erhalten.',
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -60,3 +61,73 @@ export async function GET() {
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const createExpenseSchema = z.object({
|
||||
categoryId: z.enum(['reise', 'software', 'hardware', 'fremdleistung', 'buero', 'marketing']),
|
||||
vendor: z.string().max(200).nullable().optional(),
|
||||
description: z.string().min(1).max(8000),
|
||||
amountCents: z.number().int().min(0),
|
||||
taxBp: z.number().int().min(0).max(10000).optional().default(1900),
|
||||
date: z.string().optional(),
|
||||
companyId: z.string().nullable().optional(),
|
||||
projectId: z.string().nullable().optional(),
|
||||
billable: z.boolean().optional().default(false),
|
||||
markupPct: z.number().int().min(0).max(500).optional().default(0),
|
||||
recurring: z.boolean().optional().default(false),
|
||||
recurringInterval: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/expenses — create a new expense.
|
||||
// Computes grossCents = amountCents * (1 + taxBp/10000). Audit-logged (GoBD).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createExpenseSchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const grossCents = Math.round(body.amountCents * (1 + body.taxBp / 10000))
|
||||
|
||||
// gapless expense number: AUS-YYYY-NNNN
|
||||
const year = new Date().getFullYear()
|
||||
const count = await db.expense.count({ where: { number: { startsWith: `AUS-${year}-` } } })
|
||||
const number = `AUS-${year}-${String(count + 1).padStart(4, '0')}`
|
||||
|
||||
const expense = await db.expense.create({
|
||||
data: {
|
||||
number,
|
||||
categoryId: body.categoryId,
|
||||
vendor: body.vendor ?? null,
|
||||
description: body.description,
|
||||
amountCents: body.amountCents,
|
||||
taxBp: body.taxBp,
|
||||
grossCents,
|
||||
date: body.date ? new Date(body.date) : new Date(),
|
||||
companyId: body.companyId ?? null,
|
||||
projectId: body.projectId ?? null,
|
||||
billable: body.billable,
|
||||
markupPct: body.markupPct,
|
||||
recurring: body.recurring,
|
||||
recurringInterval: body.recurring ? body.recurringInterval ?? null : null,
|
||||
status: 'recorded',
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'expense:create',
|
||||
entity: 'Expense',
|
||||
entityId: expense.id,
|
||||
after: JSON.stringify({ number, description: expense.description, grossCents }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ expense }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createGraphifyClientFromEnv } from '@/lib/graphify'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/graphify-health
|
||||
// Checks if the Graphify MCP HTTP service is configured and reachable.
|
||||
// Used by the instance detail UI to show whether graphify is available.
|
||||
export async function GET() {
|
||||
const url = process.env.GRAPHIFY_URL
|
||||
if (!url) {
|
||||
return NextResponse.json({
|
||||
configured: false,
|
||||
ok: false,
|
||||
message: 'Graphify not configured (GRAPHIFY_URL missing)',
|
||||
})
|
||||
}
|
||||
|
||||
const client = createGraphifyClientFromEnv()
|
||||
if (!client) {
|
||||
return NextResponse.json({ configured: true, ok: false, message: 'client init failed' })
|
||||
}
|
||||
|
||||
const health = await client.health()
|
||||
return NextResponse.json({
|
||||
configured: true,
|
||||
ok: health.ok,
|
||||
url,
|
||||
version: health.version,
|
||||
message: health.ok ? 'Graphify service is up' : 'Graphify service unreachable',
|
||||
})
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns'
|
||||
import { createDokployClientFromEnv } from '@/lib/dokploy'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const postSchema = z.object({
|
||||
domain: z.string().min(3).max(253).regex(/^[a-zA-Z0-9.-]+$/, 'invalid domain'),
|
||||
})
|
||||
|
||||
// POST /api/aos/instances/[id]/custom-domain
|
||||
// Step 1: Client requests to attach a custom domain.
|
||||
// We generate a verification token and return the DNS records the client must add.
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = postSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'invalid domain', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const domain = parsed.data.domain.toLowerCase()
|
||||
|
||||
// Validate domain format
|
||||
if (domain.startsWith('-') || domain.endsWith('-') || domain.includes('..')) {
|
||||
return NextResponse.json({ error: 'invalid domain format' }, { status: 400 })
|
||||
}
|
||||
// Reject IP addresses, wildcard domains, and platform-owned domains
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(domain)) {
|
||||
return NextResponse.json({ error: 'IP addresses not allowed' }, { status: 400 })
|
||||
}
|
||||
if (domain.startsWith('*.')) {
|
||||
return NextResponse.json({ error: 'wildcard domains not allowed' }, { status: 400 })
|
||||
}
|
||||
const platformDomain = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu'
|
||||
if (domain === platformDomain || domain.endsWith(`.${platformDomain}`)) {
|
||||
return NextResponse.json({ error: 'platform-owned domains not allowed' }, { status: 400 })
|
||||
}
|
||||
|
||||
const instance = await db.instance.findUnique({ where: { id } })
|
||||
if (!instance) {
|
||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||
}
|
||||
if (!['active', 'demo_active'].includes(instance.status)) {
|
||||
return NextResponse.json({ error: `cannot attach domain to instance in status ${instance.status}` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check that the domain isn't already claimed by another instance
|
||||
const existing = await db.instance.findFirst({
|
||||
where: { customDomain: domain, id: { not: id } },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'domain already claimed by another instance' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
const crypto = await import('crypto')
|
||||
const token = `dfm-verify-${crypto.randomBytes(16).toString('hex')}`
|
||||
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
customDomain: domain,
|
||||
domainVerificationToken: token,
|
||||
domainVerifiedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'domain.requested',
|
||||
step: 'domain',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ domain, token }),
|
||||
},
|
||||
})
|
||||
|
||||
// Return the DNS records the client must add
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
domain,
|
||||
verificationToken: token,
|
||||
dnsRecords: [
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `_dfm-verify.${domain}`,
|
||||
value: token,
|
||||
purpose: 'Domain ownership verification',
|
||||
},
|
||||
{
|
||||
type: 'CNAME',
|
||||
name: domain,
|
||||
value: `${instance.subdomain}.${platformDomain}`,
|
||||
purpose: 'Point domain to the instance',
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
`1. Füge einen TXT-Record hinzu: _dfm-verify.${domain} = ${token}`,
|
||||
`2. Füge einen CNAME-Record hinzu: ${domain} → ${instance.subdomain}.${platformDomain}`,
|
||||
`3. Warte 5-15 Minuten bis die DNS-Änderungen wirksam sind.`,
|
||||
`4. Rufe dann die Verifizierung auf: PATCH /api/aos/instances/${id}/custom-domain`,
|
||||
],
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/aos/instances/[id]/custom-domain
|
||||
// Step 2: Verify the DNS records and attach the domain in Dokploy.
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const instance = await db.instance.findUnique({ where: { id } })
|
||||
if (!instance) {
|
||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||
}
|
||||
if (!instance.customDomain || !instance.domainVerificationToken) {
|
||||
return NextResponse.json({ error: 'no custom domain requested — POST first to request one' }, { status: 400 })
|
||||
}
|
||||
if (instance.domainVerifiedAt) {
|
||||
return NextResponse.json({ error: 'domain already verified' }, { status: 409 })
|
||||
}
|
||||
|
||||
const dns = createHetznerDnsClientFromEnv()
|
||||
if (!dns) {
|
||||
return NextResponse.json({ error: 'DNS client not configured' }, { status: 503 })
|
||||
}
|
||||
|
||||
// 1. Verify TXT record
|
||||
const txtResult = await dns.verifyTxtRecord(
|
||||
`_dfm-verify.${instance.customDomain}`,
|
||||
instance.domainVerificationToken
|
||||
)
|
||||
if (!txtResult.verified) {
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'domain.verify.txt.failed',
|
||||
step: 'domain',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: txtResult.error }),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
verified: false,
|
||||
step: 'txt',
|
||||
error: txtResult.error,
|
||||
message: 'TXT-Record noch nicht gefunden. Warte einige Minuten und versuche es erneut.',
|
||||
}, { status: 202 })
|
||||
}
|
||||
|
||||
// 2. Verify CNAME record
|
||||
const platformDomain = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu'
|
||||
const expectedTarget = `${instance.subdomain}.${platformDomain}`
|
||||
const cnameResult = await dns.verifyCustomDomainCname(instance.customDomain, expectedTarget)
|
||||
if (!cnameResult.verified) {
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'domain.verify.cname.failed',
|
||||
step: 'domain',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: cnameResult.error, found: cnameResult.found }),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
verified: false,
|
||||
step: 'cname',
|
||||
error: cnameResult.error,
|
||||
message: `CNAME zeigt auf "${cnameResult.found ?? 'nichts'}", erwartet: "${expectedTarget}"`,
|
||||
}, { status: 202 })
|
||||
}
|
||||
|
||||
// 3. Both verified — attach domain in Dokploy
|
||||
const dokploy = createDokployClientFromEnv()
|
||||
if (dokploy && instance.dokployAppId) {
|
||||
try {
|
||||
await dokploy.createDomain({
|
||||
applicationId: instance.dokployAppId,
|
||||
host: instance.customDomain,
|
||||
port: 3000,
|
||||
https: true,
|
||||
certificateType: 'letsencrypt',
|
||||
})
|
||||
} catch (e) {
|
||||
// Non-fatal — domain verification still succeeded, Dokploy attach can retry
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'domain.dokploy.attach.error',
|
||||
step: 'domain',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Mark verified
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: { domainVerifiedAt: new Date() },
|
||||
})
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'domain.verified',
|
||||
step: 'domain',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ domain: instance.customDomain }),
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'instance.domain.verified',
|
||||
entity: 'Instance',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ domain: instance.customDomain }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
verified: true,
|
||||
domain: instance.customDomain,
|
||||
message: `Domain ${instance.customDomain} verifiziert und in Dokploy hinterlegt.`,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/instances/[id]/custom-domain
|
||||
// Remove the custom domain (keeps the platform subdomain active).
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const instance = await db.instance.findUnique({ where: { id } })
|
||||
if (!instance) {
|
||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||
}
|
||||
if (!instance.customDomain) {
|
||||
return NextResponse.json({ error: 'no custom domain set' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Remove from Dokploy (best-effort)
|
||||
const dokploy = createDokployClientFromEnv()
|
||||
if (dokploy && instance.dokployAppId) {
|
||||
try {
|
||||
// We'd need the domain ID from Dokploy — for now, log the removal
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'domain.removed',
|
||||
step: 'domain',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ domain: instance.customDomain }),
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
customDomain: null,
|
||||
domainVerificationToken: null,
|
||||
domainVerifiedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, message: 'Custom domain removed' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { buildGraphCommand, graphPathForInstance } from '@/lib/graphify'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 600 // 10 min — graph build can take a while
|
||||
|
||||
// POST /api/aos/instances/[id]/graph-build
|
||||
// Triggers a Graphify graph build for this instance's codebase.
|
||||
// Can be called by:
|
||||
// 1. The AOS UI (admin "Graph neu erstellen" button)
|
||||
// 2. An external worker (cron job) that polls for queued graphs
|
||||
// 3. The provisioning hook (fire-and-forget)
|
||||
//
|
||||
// The actual build runs via the Graphify CLI in a subprocess. In production,
|
||||
// this should be delegated to a worker container that has Graphify installed.
|
||||
// For dev/demo: we shell out directly (requires graphify installed on host).
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const instance = await db.instance.findUnique({
|
||||
where: { id },
|
||||
include: { license: true },
|
||||
})
|
||||
if (!instance) {
|
||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||
}
|
||||
if (instance.status === 'deleted' || instance.status === 'deleting') {
|
||||
return NextResponse.json({ error: 'cannot build graph for deleted instance' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Parse metadata for git repo info
|
||||
const meta = instance.metadata ? JSON.parse(instance.metadata) as { gitRepo?: string; gitBranch?: string } : {}
|
||||
const gitRepo = meta.gitRepo ?? process.env.INSTANCE_GIT_REPO ?? 'https://github.com/cetinmustafa83/restov1'
|
||||
const gitBranch = meta.gitBranch ?? process.env.INSTANCE_GIT_BRANCH ?? 'main'
|
||||
const graphPath = graphPathForInstance(instance.id)
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'graphify.build.start',
|
||||
step: 'graphify',
|
||||
result: 'pending',
|
||||
details: JSON.stringify({ gitRepo, gitBranch, graphPath }),
|
||||
},
|
||||
})
|
||||
|
||||
// If Graphify service is not configured, return the command for an
|
||||
// external worker to execute. This allows the build to be picked up
|
||||
// by a separate worker container.
|
||||
if (!process.env.GRAPHIFY_URL) {
|
||||
const cmd = buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath })
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: 'queued',
|
||||
message: 'Graphify service not configured — build command generated for external worker.',
|
||||
graphPath,
|
||||
command: cmd,
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
})
|
||||
}
|
||||
|
||||
// Graphify is configured — check if the MCP HTTP service is reachable
|
||||
const { createGraphifyClientFromEnv } = await import('@/lib/graphify')
|
||||
const client = createGraphifyClientFromEnv()
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Graphify client init failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
const health = await client.health()
|
||||
if (!health.ok) {
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'graphify.build.error',
|
||||
step: 'graphify',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: 'Graphify service unreachable' }),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: 'Graphify service unreachable',
|
||||
command: buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath }),
|
||||
}, { status: 503 })
|
||||
}
|
||||
|
||||
// The MCP HTTP service is query-only. The actual build must be done
|
||||
// via the CLI in a worker. We return the command so the caller (or a
|
||||
// cron job) can execute it.
|
||||
const cmd = buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath })
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: 'command-ready',
|
||||
message: 'Graphify service is up. Execute the build command in a worker.',
|
||||
graphPath,
|
||||
command: cmd,
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
graphifyVersion: health.version,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// GET — returns the current graph build status for this instance
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const events = await db.instanceEvent.findMany({
|
||||
where: { instanceId: id, type: { startsWith: 'graphify.' } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
})
|
||||
|
||||
const buildStarted = events.some((e) => e.type === 'graphify.build.start' || e.type === 'graphify.build.queued')
|
||||
const buildDone = events.some((e) => e.type === 'graphify.build.ok')
|
||||
const buildFailed = events.some((e) => e.type === 'graphify.build.error')
|
||||
|
||||
let status: 'none' | 'queued' | 'building' | 'ready' | 'failed'
|
||||
if (buildDone) status = 'ready'
|
||||
else if (buildFailed) status = 'failed'
|
||||
else if (buildStarted) status = 'building'
|
||||
else status = 'none'
|
||||
|
||||
return NextResponse.json({
|
||||
status,
|
||||
events: events.map((e) => ({
|
||||
type: e.type,
|
||||
result: e.result,
|
||||
details: e.details,
|
||||
createdAt: e.createdAt,
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/aos/instances/[id]/graph-status
|
||||
// Called by the graph build worker after it finishes (success or failure).
|
||||
// Body: { status: 'ok' | 'error', stats?: {...}, error?: '...' }
|
||||
//
|
||||
// This is the callback endpoint that completes the graph build lifecycle:
|
||||
// 1. AOS queues build (graphify.build.queued)
|
||||
// 2. Worker picks up, calls POST /graph-build, gets the command
|
||||
// 3. Worker executes graphify CLI
|
||||
// 4. Worker calls this endpoint to report success/failure
|
||||
// 5. AOS records graphify.build.ok / graphify.build.error
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const status = body.status === 'ok' ? 'ok' : 'error'
|
||||
|
||||
const instance = await db.instance.findUnique({ where: { id } })
|
||||
if (!instance) {
|
||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (status === 'ok') {
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'graphify.build.ok',
|
||||
step: 'graphify',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({
|
||||
stats: body.stats ?? null,
|
||||
graphPath: body.graphPath ?? null,
|
||||
builtAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'graphify-worker',
|
||||
action: 'graphify.build.ok',
|
||||
entity: 'Instance',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ stats: body.stats }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ ok: true, status: 'ok' })
|
||||
} else {
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'graphify.build.error',
|
||||
step: 'graphify',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: body.error ?? 'unknown build error' }),
|
||||
},
|
||||
})
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
lastError: `graphify build failed: ${body.error ?? 'unknown'}`,
|
||||
lastErrorAt: new Date(),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ ok: false, status: 'error', error: body.error })
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Regular → Executable
+86
-37
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300
|
||||
|
||||
const patchSchema = z.object({
|
||||
action: z.enum(['suspend', 'reactivate', 'extend', 'retry']),
|
||||
action: z.enum(['suspend', 'reactivate', 'extend', 'retry', 'redeploy', 'stop', 'start']),
|
||||
extendDays: z.number().int().min(1).max(90).optional(),
|
||||
})
|
||||
|
||||
@@ -143,6 +143,68 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
return NextResponse.json({ ok: true, status: 'requested', message: 'Instance queued for re-provisioning' })
|
||||
}
|
||||
|
||||
if (action === 'redeploy') {
|
||||
if (!['demo_active', 'active', 'failed'].includes(instance.status)) {
|
||||
return NextResponse.json({ error: `cannot redeploy instance in status ${instance.status}` }, { status: 400 })
|
||||
}
|
||||
if (dokploy && instance.dokployAppId) {
|
||||
try {
|
||||
await dokploy.redeployApplication(instance.dokployAppId)
|
||||
} catch (e) {
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'redeploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||||
})
|
||||
}
|
||||
}
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'redeploy', step: 'done', result: 'ok' },
|
||||
})
|
||||
return NextResponse.json({ ok: true, status: 'redeploying' })
|
||||
}
|
||||
|
||||
if (action === 'stop') {
|
||||
if (!['demo_active', 'active'].includes(instance.status)) {
|
||||
return NextResponse.json({ error: `cannot stop instance in status ${instance.status}` }, { status: 400 })
|
||||
}
|
||||
if (dokploy && instance.dokployAppId) {
|
||||
try {
|
||||
await dokploy.stopApplication(instance.dokployAppId)
|
||||
} catch (e) {
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'stop.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||||
})
|
||||
}
|
||||
}
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'stop', step: 'done', result: 'ok' },
|
||||
})
|
||||
return NextResponse.json({ ok: true, status: 'stopped' })
|
||||
}
|
||||
|
||||
if (action === 'start') {
|
||||
if (!['grace', 'failed'].includes(instance.status)) {
|
||||
return NextResponse.json({ error: `cannot start instance in status ${instance.status}` }, { status: 400 })
|
||||
}
|
||||
if (dokploy && instance.dokployAppId) {
|
||||
try {
|
||||
await dokploy.startApplication(instance.dokployAppId)
|
||||
} catch (e) {
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'start.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||||
})
|
||||
}
|
||||
}
|
||||
const newStatus = instance.demoExpiresAt && new Date(instance.demoExpiresAt) > new Date() ? 'demo_active' : 'active'
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: { status: newStatus, graceEndsAt: null },
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'start', step: 'done', result: 'ok', details: JSON.stringify({ status: newStatus }) },
|
||||
})
|
||||
return NextResponse.json({ ok: true, status: newStatus })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'unknown action' }, { status: 400 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
@@ -152,7 +214,14 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
|
||||
// DELETE /api/aos/instances/[id] — tear down instance (idempotent, resumable)
|
||||
// Removes: Dokploy domain → database → application + volumes → DNS record.
|
||||
// Never deletes the Instance DB row (keeps audit record) — sets status=deleted.
|
||||
//
|
||||
// HARD DELETE: the Instance DB row is deleted entirely (cascade removes
|
||||
// InstanceEvent + ProvisioningJob). Instances are infrastructure, not
|
||||
// accounting records — they have NO GoBD retention requirement. Only the
|
||||
// accounting module (muhasebe/buchhaltung: invoices, expenses, kassenbuch,
|
||||
// belege, buchungssätze, licenses) is exempt from hard-deletion for GoBD.
|
||||
// The cross-cutting AuditLog entry (separate table, string entityId) is the
|
||||
// persistent record that this action happened.
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
@@ -160,9 +229,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
||||
if (!instance) {
|
||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||
}
|
||||
if (instance.status === 'deleted') {
|
||||
return NextResponse.json({ ok: true, message: 'already deleted', steps: [] })
|
||||
}
|
||||
|
||||
await db.instance.update({ where: { id }, data: { status: 'deleting' } })
|
||||
await db.instanceEvent.create({
|
||||
@@ -184,8 +250,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
||||
try {
|
||||
const result = await dokploy.teardownInstance({
|
||||
dokployAppId: instance.dokployAppId ?? undefined,
|
||||
dokployDbId: instance.dokployDbId ?? undefined,
|
||||
dnsDomainId: meta.dokployDomainId,
|
||||
dokployDomainId: meta.dokployDomainId,
|
||||
})
|
||||
steps.push(...result.steps)
|
||||
await db.instanceEvent.create({
|
||||
@@ -224,8 +289,8 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
// 3. Decide final state.
|
||||
// - Both succeeded → 'deleted' (clear all IDs + secrets)
|
||||
// - Either failed → 'failed' (keep IDs for reconciler retry)
|
||||
// - Both succeeded → hard-delete the instance row (no GoBD on infra)
|
||||
// - Either failed → keep row as 'failed' (retain IDs for reconciler retry)
|
||||
const now = new Date()
|
||||
if (dokployFailed || dnsFailed) {
|
||||
await db.instance.update({
|
||||
@@ -258,44 +323,28 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
// 4. Both succeeded → mark deleted (keep row for audit — GoBD)
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'deleted',
|
||||
deletedAt: now,
|
||||
dokployAppId: null,
|
||||
dokployDbId: null,
|
||||
dnsRecordId: null,
|
||||
dokployDbPassword: null, // clear secrets
|
||||
adminLoginToken: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 5. Unlink from license (keep license, just remove instance ref)
|
||||
if (instance.licenseId) {
|
||||
await db.license.update({
|
||||
where: { id: instance.licenseId },
|
||||
data: { instance: { disconnect: true } },
|
||||
})
|
||||
}
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'delete.ok', step: 'done', result: 'ok', details: JSON.stringify({ steps, deletedAt: now }) },
|
||||
})
|
||||
|
||||
// 4. Both succeeded → hard-delete the instance row.
|
||||
// Instances are infrastructure (not accounting), so there is no GoBD
|
||||
// retention requirement on the row itself. Cascade deletes the
|
||||
// InstanceEvent + ProvisioningJob rows. The AuditLog entry (separate
|
||||
// table) is the durable record of this action.
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'instance.delete',
|
||||
entity: 'Instance',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ subdomain: instance.subdomain, steps }),
|
||||
before: JSON.stringify({ subdomain: instance.subdomain, status: instance.status, product: instance.product, companyId: instance.companyId }),
|
||||
after: JSON.stringify({ subdomain: instance.subdomain, steps, deletedAt: now }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, status: 'deleted', steps, deletedAt: now })
|
||||
// Hard-delete: License 1:1 reverse relation is automatically cleared
|
||||
// (the FK lives on the Instance row). Cascade removes events + jobs.
|
||||
await db.instance.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, steps, deletedAt: now })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
|
||||
Regular → Executable
+22
-18
@@ -8,9 +8,12 @@ export const maxDuration = 300
|
||||
|
||||
// POST /api/aos/instances/cleanup
|
||||
// Cron job: run daily. Finds instances whose grace period has elapsed and
|
||||
// tears them down (Dokploy + DNS + volumes). Also finds expired demos and
|
||||
// moves them to grace. Idempotent — safe to call multiple times.
|
||||
// tears them down (Dokploy + DNS + volumes), then HARD-DELETES the instance
|
||||
// row. Also finds expired demos and moves them to grace. Idempotent — safe
|
||||
// to call multiple times.
|
||||
//
|
||||
// Instances are infrastructure, not accounting records — no GoBD retention.
|
||||
// The cross-cutting AuditLog entry (separate table) records each deletion.
|
||||
// In production, this is triggered by a cron job (e.g. via Dokploy's cron
|
||||
// or a GitHub Action hitting this endpoint with a shared secret).
|
||||
export async function POST(req: Request) {
|
||||
@@ -88,8 +91,7 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
const r = await dokploy.teardownInstance({
|
||||
dokployAppId: inst.dokployAppId ?? undefined,
|
||||
dokployDbId: inst.dokployDbId ?? undefined,
|
||||
dnsDomainId: meta.dokployDomainId,
|
||||
dokployDomainId: meta.dokployDomainId,
|
||||
})
|
||||
steps.push(...r.steps)
|
||||
} catch (e) {
|
||||
@@ -127,21 +129,23 @@ export async function POST(req: Request) {
|
||||
results.failed++
|
||||
results.details.push(`${inst.subdomain}: PARTIAL (${steps.join(', ')}) — retry next run`)
|
||||
} else {
|
||||
// Both succeeded → mark deleted (clear IDs + secrets)
|
||||
await db.instance.update({
|
||||
where: { id: inst.id },
|
||||
// Both succeeded → hard-delete the instance row.
|
||||
// Instances are infrastructure (not accounting) — no GoBD retention.
|
||||
// Cascade removes InstanceEvent + ProvisioningJob. The AuditLog
|
||||
// entry (separate table) is the durable record.
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
status: 'deleted',
|
||||
deletedAt: now,
|
||||
dokployAppId: null,
|
||||
dokployDbId: null,
|
||||
dokployDbPassword: null,
|
||||
dnsRecordId: null,
|
||||
adminLoginToken: null,
|
||||
actor: 'cron:cleanup',
|
||||
action: 'instance.delete',
|
||||
entity: 'Instance',
|
||||
entityId: inst.id,
|
||||
before: JSON.stringify({ subdomain: inst.subdomain, status: inst.status, product: inst.product }),
|
||||
after: JSON.stringify({ subdomain: inst.subdomain, steps, deletedAt: now }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
// Unlink license
|
||||
// Unlink license first (1:1 reverse relation) then hard-delete.
|
||||
if (inst.licenseId) {
|
||||
await db.license.update({
|
||||
where: { id: inst.licenseId },
|
||||
@@ -149,9 +153,9 @@ export async function POST(req: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: inst.id, type: 'cleanup.delete.ok', step: 'reconciler', result: 'ok', details: JSON.stringify({ steps }) },
|
||||
})
|
||||
await db.instance.delete({ where: { id: inst.id } })
|
||||
// NOTE: no post-delete InstanceEvent — the row is gone and cascade
|
||||
// would wipe it anyway. The AuditLog entry above is the durable record.
|
||||
results.deleted++
|
||||
results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`)
|
||||
}
|
||||
|
||||
Regular → Executable
+113
-16
@@ -21,10 +21,8 @@ const GRACE_DAYS = 7
|
||||
const createSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
licenseId: z.string().min(1),
|
||||
product: z.string().min(1).default('restov1'),
|
||||
allowedRepoId: z.string().min(1), // must reference an AllowedRepo
|
||||
plan: z.enum(['starter', 'pro', 'enterprise']).default('pro'),
|
||||
gitRepo: z.string().url().default('https://github.com/cetinmustafa83/restov1'),
|
||||
gitBranch: z.string().default('main'),
|
||||
// Extra env vars to inject into the instance (beyond the standard ones)
|
||||
envVars: z.record(z.string(), z.string()).optional(),
|
||||
// If true, start as a demo (14-day expiry). If false, start as active (paid).
|
||||
@@ -72,7 +70,7 @@ export async function POST(req: Request) {
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { companyId, licenseId, product, plan, gitRepo, gitBranch, envVars, isDemo } = parsed.data
|
||||
const { companyId, licenseId, allowedRepoId, plan, envVars, isDemo } = parsed.data
|
||||
|
||||
// Verify company + license exist and belong together
|
||||
const company = await db.company.findUnique({ where: { id: companyId } })
|
||||
@@ -83,9 +81,37 @@ export async function POST(req: Request) {
|
||||
if (license.companyId !== companyId) {
|
||||
return NextResponse.json({ error: 'license does not belong to this company' }, { status: 400 })
|
||||
}
|
||||
if (license.product !== product) {
|
||||
return NextResponse.json({ error: 'license product does not match instance product' }, { status: 400 })
|
||||
|
||||
// --- Repo permission check ---
|
||||
// Admin must have added this repo to the allowed list AND either:
|
||||
// a) the repo is public (isPublic=true), OR
|
||||
// b) the repo is assigned to this company (RepoAssignment exists)
|
||||
const allowedRepo = await db.allowedRepo.findUnique({
|
||||
where: { id: allowedRepoId },
|
||||
include: {
|
||||
assignments: { where: { companyId } },
|
||||
},
|
||||
})
|
||||
if (!allowedRepo) {
|
||||
return NextResponse.json({ error: 'repo not found in allowed list' }, { status: 404 })
|
||||
}
|
||||
if (!allowedRepo.isActive) {
|
||||
return NextResponse.json({ error: 'repo is disabled' }, { status: 403 })
|
||||
}
|
||||
const hasAccess = allowedRepo.isPublic || allowedRepo.assignments.length > 0
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'repo is not assigned to this company and is not public' }, { status: 403 })
|
||||
}
|
||||
|
||||
const product = allowedRepo.product
|
||||
if (license.product !== product) {
|
||||
return NextResponse.json({ error: `license product (${license.product}) does not match repo product (${product})` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Always use Gitea — git.dfmpanel.eu is our internal repo server
|
||||
const GITEA_URL = process.env.GITEA_URL ?? 'https://git.dfmpanel.eu'
|
||||
const gitRepo = `${GITEA_URL}/${allowedRepo.giteaOwner}/${allowedRepo.giteaRepo}.git`
|
||||
const gitBranch = 'main' // always main
|
||||
|
||||
// Check that no live instance already exists for this license
|
||||
const existing = await db.instance.findFirst({
|
||||
@@ -119,6 +145,7 @@ export async function POST(req: Request) {
|
||||
plan,
|
||||
subdomain,
|
||||
demoExpiresAt: isDemo ? new Date(Date.now() + DEMO_DAYS * 86400000) : null,
|
||||
allowedRepoId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -128,7 +155,7 @@ export async function POST(req: Request) {
|
||||
type: 'provision.start',
|
||||
step: 'init',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo }),
|
||||
details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo, allowedRepo: allowedRepo.displayName }),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -141,14 +168,24 @@ export async function POST(req: Request) {
|
||||
throw new Error('Dokploy client not configured (DOKPLOY_URL / DOKPLOY_API_KEY missing)')
|
||||
}
|
||||
|
||||
// Instance env vars: license key + AOS connection + product-specific
|
||||
// Parse repo-specific env template (if set by admin)
|
||||
const repoEnvTemplate: Record<string, string> = allowedRepo.envTemplate
|
||||
? JSON.parse(allowedRepo.envTemplate)
|
||||
: {}
|
||||
|
||||
// Instance env vars: license + AOS + repo template + overrides
|
||||
const instanceEnv: Record<string, string> = {
|
||||
// AOS license server connection
|
||||
AOS_LICENSE_SERVER_URL: process.env.AOS_PUBLIC_URL ?? `https://${PLATFORM_DOMAIN}`,
|
||||
AOS_PRODUCT: product,
|
||||
AOS_LICENSE_KEY: license.key,
|
||||
AOS_LICENSE_DOMAIN: fullDomain,
|
||||
AOS_OFFLINE_GRACE_HOURS: '72',
|
||||
// Standard runtime
|
||||
NODE_ENV: 'production',
|
||||
// Repo-specific defaults from admin (APP_MODE, AUTH_SECRET, etc.)
|
||||
...repoEnvTemplate,
|
||||
// Caller overrides (highest priority)
|
||||
...envVars,
|
||||
}
|
||||
|
||||
@@ -158,7 +195,7 @@ export async function POST(req: Request) {
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
envVars: instanceEnv,
|
||||
databaseName: `${product}_${subdomain}`.replace(/-/g, '_').slice(0, 40),
|
||||
gitProvider: 'gitea', // always Gitea
|
||||
cpuLimit: plan === 'enterprise' ? '2' : plan === 'pro' ? '1' : '0.5',
|
||||
memoryLimit: plan === 'enterprise' ? '2048m' : plan === 'pro' ? '1024m' : '512m',
|
||||
}
|
||||
@@ -183,8 +220,7 @@ export async function POST(req: Request) {
|
||||
result: 'ok',
|
||||
details: JSON.stringify({
|
||||
appId: result.app.applicationId,
|
||||
dbId: result.database?.databaseId,
|
||||
domainId: result.domain.domainId,
|
||||
domainId: result.domain?.domainId,
|
||||
url: result.url,
|
||||
}),
|
||||
},
|
||||
@@ -227,15 +263,11 @@ export async function POST(req: Request) {
|
||||
data: {
|
||||
status: isDemo ? 'demo_active' : 'active',
|
||||
dokployAppId: result.app.applicationId,
|
||||
dokployDbId: result.database?.databaseId ?? null,
|
||||
dokployDbName: result.database?.database ?? null,
|
||||
dokployDbUser: result.database?.username ?? null,
|
||||
dokployDbPassword: result.database?.password ?? null,
|
||||
dnsRecordId,
|
||||
metadata: JSON.stringify({
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
dokployDomainId: result.domain.domainId,
|
||||
dokployDomainId: result.domain?.domainId ?? null,
|
||||
url: result.url,
|
||||
provisionedAt: now.toISOString(),
|
||||
}),
|
||||
@@ -258,6 +290,45 @@ export async function POST(req: Request) {
|
||||
},
|
||||
})
|
||||
|
||||
// --- Generate admin login token (one-time, 7-day expiry) ---
|
||||
// This token allows the client to access their instance admin panel
|
||||
// without separate credentials. Sent via email (if SMTP configured).
|
||||
const crypto = await import('crypto')
|
||||
const adminToken = `admin-${crypto.randomBytes(24).toString('hex')}`
|
||||
const adminTokenExpires = new Date(Date.now() + 7 * 86400000)
|
||||
await db.instance.update({
|
||||
where: { id: instance.id },
|
||||
data: {
|
||||
adminLoginToken: adminToken,
|
||||
adminLoginExpiresAt: adminTokenExpires,
|
||||
},
|
||||
})
|
||||
|
||||
// --- Graphify: trigger background graph build ---
|
||||
// Builds a knowledge graph from the instance's Git repo so the support
|
||||
// ticket AI can query the codebase later. Fire-and-forget: we don't
|
||||
// await this — it runs in the background and takes 30s-5min depending
|
||||
// on repo size. The graph build job is recorded as an instance event.
|
||||
if (process.env.GRAPHIFY_URL) {
|
||||
import('@/lib/graphify').then(({ graphPathForInstance, buildGraphCommand }) => {
|
||||
const graphPath = graphPathForInstance(instance.id)
|
||||
const cmd = buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath })
|
||||
db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'graphify.build.queued',
|
||||
step: 'graphify',
|
||||
result: 'pending',
|
||||
details: JSON.stringify({ graphPath, gitRepo, gitBranch }),
|
||||
},
|
||||
}).then(() => {
|
||||
// In production: enqueue to a worker (BullMQ, Dokploy cron, etc.)
|
||||
// For now: log the command that should be executed.
|
||||
console.log(`[graphify] Instance ${instance.id} graph build queued: ${cmd}`)
|
||||
}).catch(() => {})
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
@@ -275,12 +346,38 @@ export async function POST(req: Request) {
|
||||
},
|
||||
})
|
||||
|
||||
// Send instance ready email with admin login link
|
||||
const { sendEmail } = await import('@/lib/email')
|
||||
if (company.email) {
|
||||
const adminLoginUrl = `${result.url}/?admin_token=${adminToken}`
|
||||
sendEmail({
|
||||
to: company.email,
|
||||
subject: `Ihre ${product}-Instanz ist bereit — ${result.url}`,
|
||||
text: `Guten Tag,\n\nIhre Instanz wurde erfolgreich bereitgestellt.\n\nURL: ${result.url}\nAdmin-Login: ${adminLoginUrl}\n${isDemo ? `Demo-Laufzeit: 14 Tage (endet am ${new Date(instance.demoExpiresAt!).toLocaleDateString('de-DE')})` : ''}\n\nMit freundlichen Grüßen\nDFM Solutions`,
|
||||
html: `<p>Guten Tag,</p><p>Ihre <strong>${product}</strong>-Instanz wurde erfolgreich bereitgestellt.</p><p><strong>URL:</strong> <a href="${result.url}">${result.url}</a><br><strong>Admin-Login:</strong> <a href="${adminLoginUrl}">${adminLoginUrl}</a></p>${isDemo ? `<p><strong>Demo-Laufzeit:</strong> 14 Tage (endet am ${new Date(instance.demoExpiresAt!).toLocaleDateString('de-DE')})</p>` : ''}<p>Mit freundlichen Grüßen<br>DFM Solutions</p>`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Dispatch webhook event
|
||||
const { dispatchWebhook } = await import('@/lib/webhook')
|
||||
dispatchWebhook('instance.provisioned', {
|
||||
instanceId: instance.id,
|
||||
subdomain,
|
||||
url: result.url,
|
||||
product,
|
||||
companyId: license.companyId,
|
||||
status: isDemo ? 'demo_active' : 'active',
|
||||
demoExpiresAt: instance.demoExpiresAt,
|
||||
}).catch(() => {})
|
||||
|
||||
return NextResponse.json({
|
||||
instanceId: instance.id,
|
||||
subdomain,
|
||||
url: result.url,
|
||||
status: isDemo ? 'demo_active' : 'active',
|
||||
demoExpiresAt: instance.demoExpiresAt,
|
||||
adminLoginToken: adminToken,
|
||||
adminLoginUrl: `${result.url}/?admin_token=${adminToken}`,
|
||||
}, { status: 201 })
|
||||
} catch (provErr) {
|
||||
// Provisioning failed — mark as failed, keep the record for retry
|
||||
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Executable
+20
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// DELETE /api/aos/licenses/[id] — DISABLED (GoBD).
|
||||
//
|
||||
// Licenses carry billing/subscription data and are accounting records.
|
||||
// Per GoBD they may be CANCELLED but never deleted. Use the dedicated
|
||||
// cancel flow at PATCH /api/aos/licenses/[id]/cancel instead — it sets
|
||||
// status='cancelled', records cancellationEffectiveAt, and writes an
|
||||
// AuditLog entry. The license row is retained indefinitely.
|
||||
export async function DELETE() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Lizenzen können nicht gelöscht werden (GoBD). Bitte über „Kündigen“ stornieren.',
|
||||
useInstead: 'PATCH /api/aos/licenses/[id]/cancel',
|
||||
},
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -304,6 +304,32 @@ export async function POST(req: Request) {
|
||||
},
|
||||
})
|
||||
|
||||
// Send license key email to the customer
|
||||
const { sendLicenseKeyEmail } = await import('@/lib/email')
|
||||
if (company.email && !parentId) {
|
||||
// Only send for core licenses (not modules)
|
||||
sendLicenseKeyEmail({
|
||||
to: company.email,
|
||||
licenseKey: key,
|
||||
product,
|
||||
plan,
|
||||
}).catch(() => {}) // fire-and-forget, don't block response
|
||||
}
|
||||
|
||||
// Dispatch webhook event
|
||||
const { dispatchWebhook } = await import('@/lib/webhook')
|
||||
dispatchWebhook('license.created', {
|
||||
licenseId: license.id,
|
||||
key,
|
||||
product,
|
||||
plan,
|
||||
companyId: license.companyId,
|
||||
companyName: company.name,
|
||||
environment,
|
||||
billingCycle,
|
||||
netPriceCents: pricing.netPriceCents,
|
||||
}).catch(() => {})
|
||||
|
||||
return NextResponse.json({ license, pricing: pricing.breakdown }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateProjectSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['website', 'shop', 'app', 'retainer']).optional(),
|
||||
status: z.enum(['planning', 'active', 'review', 'done', 'on-hold', 'cancelled']).optional(),
|
||||
budgetType: z.enum(['fixed', 'time-materials', 'retainer']).optional(),
|
||||
budgetHours: z.number().int().min(0).nullable().optional(),
|
||||
spentHours: z.number().int().min(0).optional(),
|
||||
fixedPriceCents: z.number().int().min(0).nullable().optional(),
|
||||
startDate: z.string().nullable().optional(),
|
||||
dueDate: z.string().nullable().optional(),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/projects/[id] — update project master data.
|
||||
// Audit-logged (GoBD). Used by the project edit dialog (name, type,
|
||||
// budget, dates, progress).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateProjectSchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const existing = await db.project.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'project not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if ((k === 'startDate' || k === 'dueDate') && typeof v === 'string') {
|
||||
data[k] = new Date(v)
|
||||
} else {
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.project.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:update',
|
||||
entity: 'Project',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ project: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/projects/[id] — delete a project.
|
||||
//
|
||||
// Cascade policy: a project with tasks, time entries, or portal approvals
|
||||
// cannot be hard-deleted (those records belong to the agency's audit trail
|
||||
// and may have been invoiced). When the project has any of these
|
||||
// dependencies, the call returns 409 with the dependency counts so the
|
||||
// UI can show a dialog explaining what blocks the deletion.
|
||||
//
|
||||
// GoBD: time entries already invoiced are never deleted through this path.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.project.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'project not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [tasks, timeEntries, approvals, invoices] = await Promise.all([
|
||||
db.task.count({ where: { projectId: id } }),
|
||||
db.timeEntry.count({ where: { projectId: id } }),
|
||||
db.portalApproval.count({ where: { projectId: id } }),
|
||||
db.invoice.count({ where: { projectId: id } }),
|
||||
])
|
||||
|
||||
const dependencies: Record<string, number> = { tasks, timeEntries, approvals, invoices }
|
||||
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
|
||||
if (totalDeps > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'project has related records — delete or reassign them first',
|
||||
dependencies,
|
||||
total: totalDeps,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.project.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:delete',
|
||||
entity: 'Project',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify({ deleted: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, id })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -23,3 +24,61 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({ projects, minutesThisWeek })
|
||||
}
|
||||
|
||||
const createProjectSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
name: z.string().min(1).max(200),
|
||||
type: z.enum(['website', 'shop', 'app', 'retainer']).default('website'),
|
||||
budgetType: z.enum(['fixed', 'time-materials', 'retainer']).default('fixed'),
|
||||
budgetHours: z.number().int().min(0).nullable().optional(),
|
||||
fixedPriceCents: z.number().int().min(0).nullable().optional(),
|
||||
startDate: z.string().nullable().optional(),
|
||||
dueDate: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/projects — create a new project.
|
||||
// Audit-logged (GoBD). The UI's "Projekt" button posts here.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createProjectSchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const company = await db.company.findUnique({ where: { id: body.companyId } })
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
companyId: body.companyId,
|
||||
name: body.name.trim(),
|
||||
type: body.type,
|
||||
status: 'planning',
|
||||
budgetType: body.budgetType,
|
||||
budgetHours: body.budgetHours ?? null,
|
||||
fixedPriceCents: body.fixedPriceCents ?? null,
|
||||
startDate: body.startDate ? new Date(body.startDate) : null,
|
||||
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:create',
|
||||
entity: 'Project',
|
||||
entityId: project.id,
|
||||
after: JSON.stringify({ name: project.name, companyId: body.companyId, type: project.type }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ project }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const patchSchema = z.object({
|
||||
displayName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
envTemplate: z.string().optional(),
|
||||
// Assign to a specific company (or remove assignment)
|
||||
assignToCompany: z.string().optional(),
|
||||
unassignFromCompany: z.string().optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/repos/[id] — update repo settings + assign/unassign
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = patchSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { displayName, description, isPublic, isActive, envTemplate, assignToCompany, unassignFromCompany } = parsed.data
|
||||
|
||||
const repo = await db.allowedRepo.findUnique({ where: { id } })
|
||||
if (!repo) {
|
||||
return NextResponse.json({ error: 'repo not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update fields
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (displayName !== undefined) updateData.displayName = displayName
|
||||
if (description !== undefined) updateData.description = description
|
||||
if (isPublic !== undefined) updateData.isPublic = isPublic
|
||||
if (isActive !== undefined) updateData.isActive = isActive
|
||||
if (envTemplate !== undefined) updateData.envTemplate = envTemplate
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.allowedRepo.update({ where: { id }, data: updateData })
|
||||
}
|
||||
|
||||
// Assign to company
|
||||
if (assignToCompany) {
|
||||
await db.repoAssignment.upsert({
|
||||
where: { allowedRepoId_companyId: { allowedRepoId: id, companyId: assignToCompany } },
|
||||
create: { allowedRepoId: id, companyId: assignToCompany },
|
||||
update: {},
|
||||
})
|
||||
}
|
||||
|
||||
// Unassign from company
|
||||
if (unassignFromCompany) {
|
||||
await db.repoAssignment.deleteMany({
|
||||
where: { allowedRepoId: id, companyId: unassignFromCompany },
|
||||
})
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'repo.update',
|
||||
entity: 'AllowedRepo',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ updateData, assignToCompany, unassignFromCompany }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/repos/[id] — remove repo from allowed list
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const repo = await db.allowedRepo.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { instances: true } } },
|
||||
})
|
||||
if (!repo) {
|
||||
return NextResponse.json({ error: 'repo not found' }, { status: 404 })
|
||||
}
|
||||
if (repo._count.instances > 0) {
|
||||
return NextResponse.json({
|
||||
error: 'cannot delete repo with active instances',
|
||||
instances: repo._count.instances,
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
await db.allowedRepo.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'repo.delete',
|
||||
entity: 'AllowedRepo',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ giteaOwner: repo.giteaOwner, giteaRepo: repo.giteaRepo }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/repos — list all allowed repos (admin)
|
||||
// GET /api/aos/repos?companyId=X — list repos available to a specific client
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const companyId = url.searchParams.get('companyId')
|
||||
|
||||
if (companyId) {
|
||||
// Client-scoped: show public repos + repos assigned to this company
|
||||
const [publicRepos, assignedRepos] = await Promise.all([
|
||||
db.allowedRepo.findMany({
|
||||
where: { isPublic: true, isActive: true },
|
||||
orderBy: { displayName: 'asc' },
|
||||
}),
|
||||
db.allowedRepo.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
assignments: { some: { companyId } },
|
||||
},
|
||||
include: { assignments: { where: { companyId } } },
|
||||
orderBy: { displayName: 'asc' },
|
||||
}),
|
||||
])
|
||||
// Merge + dedupe
|
||||
const seen = new Set<string>()
|
||||
const repos = [...publicRepos, ...assignedRepos].filter((r) => {
|
||||
if (seen.has(r.id)) return false
|
||||
seen.add(r.id)
|
||||
return true
|
||||
})
|
||||
return NextResponse.json({ repos })
|
||||
}
|
||||
|
||||
// Admin: list all repos
|
||||
const repos = await db.allowedRepo.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { assignments: true, instances: true } },
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ repos })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
giteaOwner: z.string().min(1),
|
||||
giteaRepo: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
product: z.string().min(1),
|
||||
isPublic: z.boolean().default(false),
|
||||
envTemplate: z.string().optional(), // JSON string of default env vars
|
||||
})
|
||||
|
||||
// POST /api/aos/repos — admin adds a repo to the allowed list
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { giteaOwner, giteaRepo, displayName, description, product, isPublic, envTemplate } = parsed.data
|
||||
|
||||
const repo = await db.allowedRepo.upsert({
|
||||
where: { giteaOwner_giteaRepo: { giteaOwner, giteaRepo } },
|
||||
create: {
|
||||
giteaOwner,
|
||||
giteaRepo,
|
||||
displayName,
|
||||
description,
|
||||
product,
|
||||
isPublic,
|
||||
envTemplate,
|
||||
},
|
||||
update: {
|
||||
displayName,
|
||||
description,
|
||||
product,
|
||||
isPublic,
|
||||
envTemplate,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'repo.add',
|
||||
entity: 'AllowedRepo',
|
||||
entityId: repo.id,
|
||||
after: JSON.stringify({ giteaOwner, giteaRepo, product, isPublic }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ repo }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+271
@@ -0,0 +1,271 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 120
|
||||
|
||||
// POST /api/aos/seed-prod
|
||||
// Production-safe seed: requires SEED_ADMIN_KEY header, runs the seed
|
||||
// directly via Prisma (no child_process, no bun dependency).
|
||||
//
|
||||
// This populates the database with demo data + activates all modules so
|
||||
// the AOS UI is fully functional after first deploy.
|
||||
//
|
||||
// Usage:
|
||||
// curl -X POST https://dfmpanel.eu/api/aos/seed-prod \
|
||||
// -H "X-Seed-Key: $SEED_ADMIN_KEY"
|
||||
export async function POST(req: Request) {
|
||||
// Auth: if SEED_ADMIN_KEY is set, require it. If NOT set, allow seeding
|
||||
// only when the database is empty (first-time bootstrap — no auth needed
|
||||
// because there's nothing to protect yet).
|
||||
const seedKey = process.env.SEED_ADMIN_KEY
|
||||
const providedKey = req.headers.get('x-seed-key') ?? new URL(req.url).searchParams.get('key')
|
||||
|
||||
if (seedKey) {
|
||||
// Auth mode: key must match
|
||||
if (providedKey !== seedKey) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
} else {
|
||||
// Bootstrap mode: only allowed when DB is empty (companyCount === 0)
|
||||
const { db } = await import('@/lib/db')
|
||||
const companyCount = await db.company.count()
|
||||
if (companyCount > 0) {
|
||||
return NextResponse.json({
|
||||
error: 'SEED_ADMIN_KEY not configured and DB is not empty. Set SEED_ADMIN_KEY env to seed a non-empty database.',
|
||||
companyCount,
|
||||
}, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stats: Record<string, number | string> = {}
|
||||
|
||||
// --- 1. Modules — activate all core modules ---
|
||||
const moduleDefs = [
|
||||
{ id: 'crm', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'leads', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'projects', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'licenses', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'contracts', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'expenses', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'estimates', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'support', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'accounting', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'sales-docs', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'finance', version: '1.0.0', enabled: true, installed: true },
|
||||
{ id: 'reporting', version: '1.0.0', enabled: true, installed: true },
|
||||
]
|
||||
for (const m of moduleDefs) {
|
||||
await db.module.upsert({
|
||||
where: { id: m.id },
|
||||
create: m,
|
||||
update: { enabled: true, installed: true },
|
||||
})
|
||||
}
|
||||
stats.modules = moduleDefs.length
|
||||
|
||||
// --- 2. Settings ---
|
||||
const settings: Record<string, string> = {
|
||||
'company.name': 'DFM Solutions',
|
||||
'company.legalForm': 'GmbH',
|
||||
'company.vatId': 'DE123456789',
|
||||
'company.registerCourt': 'Amtsgericht Hamburg',
|
||||
'company.registerNumber': 'HRB 123456',
|
||||
'company.city': 'Hamburg',
|
||||
'invoice.numberPrefix': 'RE-2026-',
|
||||
'tax.standardBp': '1900',
|
||||
'leadFinder.radiusKm': '50',
|
||||
}
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db.setting.upsert({ where: { key }, create: { key, value }, update: { value } })
|
||||
}
|
||||
stats.settings = Object.keys(settings).length
|
||||
|
||||
// --- 3. Admin user ---
|
||||
const existingUser = await db.user.findUnique({ where: { email: 'admin@dfmpanel.eu' } })
|
||||
if (!existingUser) {
|
||||
await db.user.create({
|
||||
data: { email: 'admin@dfmpanel.eu', name: 'Max Brandt', role: 'owner' },
|
||||
})
|
||||
stats.users = 1
|
||||
} else {
|
||||
stats.users = 0
|
||||
}
|
||||
|
||||
// --- 4. Companies ---
|
||||
const companies = [
|
||||
{ name: 'Pizzeria RESTO GmbH', legalForm: 'GmbH', vatId: 'DE298765432', website: 'pizzeria-resto.de', email: 'bestellung@pizzeria-resto.de', phone: '+49 40 2280 15', city: 'Hamburg', zip: '22765', street: 'Ottenser Hauptstraße 42', source: 'referral', tags: 'food,restov1,online-bestellung' },
|
||||
{ name: 'Nordwind Logistik GmbH', legalForm: 'GmbH', vatId: 'DE440281115', website: 'nordwind-logistik.de', email: 'info@nordwind-logistik.de', phone: '+49 421 500 88', city: 'Bremen', zip: '28195', street: 'Am Markt 12', source: 'referral', tags: 'logistik,maintenance' },
|
||||
{ name: 'HanseMöbel Manufaktur GmbH', legalForm: 'GmbH', vatId: 'DE118823440', website: 'hansemoebel.de', email: 'verkauf@hansemoebel.de', city: 'Hamburg', zip: '21107', street: 'Stuhlrother Str. 5', source: 'lead-finder', tags: 'shop,woocommerce' },
|
||||
{ name: 'TechStart UG (haftungsbeschränkt)', legalForm: 'UG', vatId: 'DE312045519', website: 'techstart.berlin', email: 'hello@techstart.berlin', city: 'Berlin', zip: '10115', street: 'Torstraße 100', source: 'lead-finder', tags: 'startup,saas' },
|
||||
{ name: 'Atelier Wendland GbR', legalForm: 'GbR', website: 'atelier-wendland.de', email: 'studio@atelier-wendland.de', city: 'Lüneburg', zip: '21335', street: 'Am Sande 4', source: 'manual', tags: 'design,redesign' },
|
||||
]
|
||||
const companyMap: Record<string, string> = {}
|
||||
for (const c of companies) {
|
||||
const existing = await db.company.findFirst({ where: { name: c.name } })
|
||||
if (existing) {
|
||||
companyMap[c.name] = existing.id
|
||||
} else {
|
||||
const created = await db.company.create({ data: c })
|
||||
companyMap[c.name] = created.id
|
||||
}
|
||||
}
|
||||
stats.companies = Object.keys(companyMap).length
|
||||
|
||||
// --- 5. Licenses (restov1 core + modules + demo) ---
|
||||
const crypto = await import('crypto')
|
||||
const sig = () => 'sig_' + crypto.randomBytes(32).toString('hex')
|
||||
|
||||
const existingLicenses = await db.license.count()
|
||||
if (existingLicenses === 0) {
|
||||
// restov1 core (production, pro, 365 days)
|
||||
const core = await db.license.create({
|
||||
data: {
|
||||
key: 'AOS-PRO-RESTO-001-CORE',
|
||||
companyId: companyMap['Pizzeria RESTO GmbH'],
|
||||
product: 'restov1',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
environment: 'production',
|
||||
maxActivations: 2,
|
||||
validUntil: new Date(Date.now() + 365 * 86400000),
|
||||
token: sig(),
|
||||
billingCycle: 'monthly',
|
||||
priceCents: 5000,
|
||||
discountPct: 0,
|
||||
netPriceCents: 5000,
|
||||
noticePeriodDays: 90,
|
||||
trialEndsAt: new Date(Date.now() + 14 * 86400000),
|
||||
subscriptionStartsAt: new Date(Date.now() + 15 * 86400000),
|
||||
},
|
||||
})
|
||||
await db.licenseActivation.create({
|
||||
data: { licenseId: core.id, domain: 'pizzeria-resto.de', environment: 'production', version: '1.0.0' },
|
||||
})
|
||||
|
||||
// restov1 modules
|
||||
const moduleKeys = [
|
||||
{ key: 'AOS-PRO-RESTO-002-MOD-ONLINE', module: 'online-order' },
|
||||
{ key: 'AOS-PRO-RESTO-003-MOD-PAYMENT', module: 'payment' },
|
||||
{ key: 'AOS-PRO-RESTO-004-MOD-RESERV', module: 'reservation' },
|
||||
{ key: 'AOS-PRO-RESTO-005-MOD-DELIVER', module: 'delivery' },
|
||||
]
|
||||
for (const m of moduleKeys) {
|
||||
await db.license.create({
|
||||
data: {
|
||||
key: m.key,
|
||||
companyId: companyMap['Pizzeria RESTO GmbH'],
|
||||
product: 'restov1',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
environment: 'production',
|
||||
module: m.module,
|
||||
parentId: core.id,
|
||||
maxActivations: 2,
|
||||
validUntil: new Date(Date.now() + 365 * 86400000),
|
||||
token: sig(),
|
||||
billingCycle: 'monthly',
|
||||
priceCents: 0,
|
||||
discountPct: 0,
|
||||
netPriceCents: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Demo license
|
||||
const demo = await db.license.create({
|
||||
data: {
|
||||
key: 'AOS-PRO-RESTO-DEMO-001',
|
||||
companyId: companyMap['Atelier Wendland GbR'],
|
||||
product: 'restov1',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
environment: 'demo',
|
||||
maxActivations: 1,
|
||||
validUntil: new Date(Date.now() + 14 * 86400000),
|
||||
token: sig(),
|
||||
billingCycle: 'monthly',
|
||||
priceCents: 5000,
|
||||
discountPct: 0,
|
||||
netPriceCents: 5000,
|
||||
noticePeriodDays: 90,
|
||||
trialEndsAt: new Date(Date.now() + 14 * 86400000),
|
||||
},
|
||||
})
|
||||
await db.licenseActivation.create({
|
||||
data: { licenseId: demo.id, domain: 'demo.pizzeria-resto.de', environment: 'demo', version: '1.0.0-beta' },
|
||||
})
|
||||
|
||||
stats.licenses = 1 + moduleKeys.length + 1
|
||||
} else {
|
||||
stats.licenses = 0
|
||||
}
|
||||
|
||||
// --- 6. API Token (for MCP access) ---
|
||||
const existingTokens = await db.apiToken.count()
|
||||
if (existingTokens === 0) {
|
||||
const adminScopes = ['mcp:read', 'mcp:write', 'crm.company:read', 'crm.deal:read', 'finance.invoice:read', 'leads:read', 'leads:write', 'support:read', 'support:write', 'licenses.license:read', 'licenses.license:write']
|
||||
// Use MCP_API_TOKEN env var if set, otherwise generate a random one
|
||||
const tokenValue = process.env.MCP_API_TOKEN || ('aos_' + crypto.randomUUID())
|
||||
const hash = crypto.createHash('sha256').update(tokenValue).digest('hex')
|
||||
await db.apiToken.create({
|
||||
data: {
|
||||
name: 'MCP Admin Token (seeded)',
|
||||
tokenHash: hash,
|
||||
tokenPrefix: tokenValue.slice(0, 12) + '…',
|
||||
scopes: adminScopes.join(','),
|
||||
expiresAt: null,
|
||||
},
|
||||
})
|
||||
stats.apiTokens = 1
|
||||
stats.mcpToken = tokenValue === process.env.MCP_API_TOKEN ? '(from env)' : tokenValue
|
||||
} else {
|
||||
stats.apiTokens = 0
|
||||
}
|
||||
|
||||
// --- 7. Allowed Repos (tenant instance provisioning) ---
|
||||
const existingRepos = await db.allowedRepo.count()
|
||||
if (existingRepos === 0) {
|
||||
const restov1EnvTemplate = JSON.stringify({
|
||||
APP_MODE: 'demo',
|
||||
RESTO_RESTAURANT_ID: 'resto_demo_01',
|
||||
RESTO_QR_SECRET: '4a27fcd1564d8557cfc8618c710476fe10f61c30e0896290567ac4656500b6d4',
|
||||
CUSTOMER_AUTH_SECRET: '6dd5f89e224607ffc41668d1d590176af38dbbdb15d8eddea9ec1011604a260e',
|
||||
STAFF_AUTH_SECRET: '8b09481986428134937be5685fe4bdc8858861e6a9b22f167506409bcd37ecc9',
|
||||
})
|
||||
await db.allowedRepo.create({
|
||||
data: {
|
||||
giteaOwner: 'dfmsolutions',
|
||||
giteaRepo: 'restov1',
|
||||
displayName: 'Pizzeria RESTO (restov1)',
|
||||
description: 'Online-Bestellsystem für Restaurants — Pizza, Pasta, Lieferung, Reservierung',
|
||||
product: 'restov1',
|
||||
isPublic: true,
|
||||
isActive: true,
|
||||
envTemplate: restov1EnvTemplate,
|
||||
},
|
||||
})
|
||||
stats.allowedRepos = 1
|
||||
} else {
|
||||
stats.allowedRepos = 0
|
||||
}
|
||||
|
||||
// --- 8. Audit log ---
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'system:seed-prod',
|
||||
action: 'seed-prod.complete',
|
||||
entity: 'System',
|
||||
entityId: 'seed',
|
||||
after: JSON.stringify(stats),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, stats, message: 'Production seed complete — all modules activated, demo licenses + companies created.' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'seed failed'
|
||||
return NextResponse.json({ error: msg, stack: err instanceof Error ? err.stack : undefined }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { createCheckoutSession } from '@/lib/stripe'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/aos/stripe/checkout
|
||||
// Creates a Stripe Checkout session for a license subscription.
|
||||
// Body: { licenseId: string }
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { licenseId } = await req.json()
|
||||
if (!licenseId) {
|
||||
return NextResponse.json({ error: 'licenseId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const license = await db.license.findUnique({
|
||||
where: { id: licenseId },
|
||||
include: { company: true },
|
||||
})
|
||||
if (!license) {
|
||||
return NextResponse.json({ error: 'license not found' }, { status: 404 })
|
||||
}
|
||||
if (license.parentId) {
|
||||
return NextResponse.json({ error: 'checkout for module licenses not supported — pay for the core license' }, { status: 400 })
|
||||
}
|
||||
if (license.netPriceCents <= 0) {
|
||||
return NextResponse.json({ error: 'this license has no price (free)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await createCheckoutSession({
|
||||
licenseId: license.id,
|
||||
licenseKey: license.key,
|
||||
product: license.product,
|
||||
plan: license.plan,
|
||||
billingCycle: license.billingCycle as 'monthly' | 'yearly',
|
||||
netPriceCents: license.netPriceCents,
|
||||
customerEmail: license.company.email ?? undefined,
|
||||
})
|
||||
|
||||
if ('error' in result) {
|
||||
return NextResponse.json({ error: result.error }, { status: 502 })
|
||||
}
|
||||
|
||||
// Audit
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'client:checkout',
|
||||
action: 'stripe.checkout.created',
|
||||
entity: 'License',
|
||||
entityId: license.id,
|
||||
after: JSON.stringify({ plan: license.plan, cycle: license.billingCycle, netPriceCents: license.netPriceCents }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: result.url })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { constructWebhookEvent } from '@/lib/stripe'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 30
|
||||
|
||||
// POST /api/aos/stripe/webhook
|
||||
// Handles Stripe webhook events:
|
||||
// - checkout.session.completed → link Stripe customer + subscription to license
|
||||
// - invoice.paid → activate license (remove trial, set to active)
|
||||
// - customer.subscription.deleted → cancel license (grace → delete)
|
||||
// - invoice.payment_failed → suspend license after dunning
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text()
|
||||
const signature = req.headers.get('stripe-signature') ?? ''
|
||||
|
||||
const event = constructWebhookEvent(body, signature)
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: 'invalid signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
const licenseId = session.metadata?.licenseId
|
||||
if (!licenseId) break
|
||||
|
||||
// Link Stripe customer + subscription to the license
|
||||
await db.license.update({
|
||||
where: { id: licenseId },
|
||||
data: {
|
||||
stripeCustomerId: session.customer as string,
|
||||
stripeSubscriptionId: session.subscription as string,
|
||||
},
|
||||
})
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'stripe:webhook',
|
||||
action: 'stripe.checkout.completed',
|
||||
entity: 'License',
|
||||
entityId: licenseId,
|
||||
after: JSON.stringify({ customerId: session.customer, subscriptionId: session.subscription }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.paid': {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
const subscriptionId = (invoice as Stripe.Invoice & { subscription?: string }).subscription
|
||||
if (!subscriptionId) break
|
||||
|
||||
// Find license by Stripe subscription ID and activate it
|
||||
const license = await db.license.findFirst({
|
||||
where: { stripeSubscriptionId: subscriptionId },
|
||||
})
|
||||
if (!license) break
|
||||
|
||||
// If license was in trial, move to active (paid subscription started)
|
||||
if (license.trialEndsAt && new Date(license.trialEndsAt) > new Date()) {
|
||||
await db.license.update({
|
||||
where: { id: license.id },
|
||||
data: {
|
||||
status: 'active',
|
||||
trialEndsAt: null,
|
||||
subscriptionStartsAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'stripe:webhook',
|
||||
action: 'stripe.invoice.paid',
|
||||
entity: 'License',
|
||||
entityId: license.id,
|
||||
after: JSON.stringify({ invoiceId: invoice.id, amount: invoice.amount_paid }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
const license = await db.license.findFirst({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
})
|
||||
if (!license) break
|
||||
|
||||
// Subscription cancelled → license goes to grace, then deletion
|
||||
// (handled by the cleanup cron job)
|
||||
const now = new Date()
|
||||
await db.license.update({
|
||||
where: { id: license.id },
|
||||
data: {
|
||||
status: 'cancelled',
|
||||
cancelledAt: now,
|
||||
cancellationEffectiveAt: new Date(now.getTime() + 7 * 86400000), // 7-day grace
|
||||
cancellationReason: 'Stripe subscription deleted',
|
||||
},
|
||||
})
|
||||
|
||||
// If there's a linked instance, schedule it for deletion too
|
||||
const instance = await db.instance.findFirst({
|
||||
where: { licenseId: license.id, status: { notIn: ['deleted', 'deleting'] } },
|
||||
})
|
||||
if (instance) {
|
||||
await db.instance.update({
|
||||
where: { id: instance.id },
|
||||
data: {
|
||||
status: 'grace',
|
||||
graceEndsAt: new Date(now.getTime() + 7 * 86400000),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'stripe:webhook',
|
||||
action: 'stripe.subscription.deleted',
|
||||
entity: 'License',
|
||||
entityId: license.id,
|
||||
after: JSON.stringify({ subscriptionId: subscription.id, graceEndsAt: now.getTime() + 7 * 86400000 }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
const subscriptionId = (invoice as Stripe.Invoice & { subscription?: string }).subscription
|
||||
if (!subscriptionId) break
|
||||
|
||||
const license = await db.license.findFirst({
|
||||
where: { stripeSubscriptionId: subscriptionId },
|
||||
})
|
||||
if (!license) break
|
||||
|
||||
// Payment failed → suspend license (dunning)
|
||||
await db.license.update({
|
||||
where: { id: license.id },
|
||||
data: {
|
||||
status: 'suspended',
|
||||
suspendReason: 'Stripe payment failed',
|
||||
suspendedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'stripe:webhook',
|
||||
action: 'stripe.payment.failed',
|
||||
entity: 'License',
|
||||
entityId: license.id,
|
||||
after: JSON.stringify({ invoiceId: invoice.id, attempt: invoice.attempt_count }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
// Unhandled event type — log but don't fail
|
||||
break
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'webhook handler failed'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
Executable
+150
@@ -0,0 +1,150 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateTicketSchema = z.object({
|
||||
status: z.enum(['open', 'pending', 'resolved', 'closed']).optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
|
||||
assignee: z.string().max(200).nullable().optional(),
|
||||
categoryId: z.string().max(50).optional(),
|
||||
subject: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/tickets/[id] — update a ticket.
|
||||
// Side effects:
|
||||
// - status='resolved' → set slaResolvedAt (if not already set)
|
||||
// - status='closed' → set slaResolvedAt (if not already set) — close implies resolution
|
||||
// - First non-empty assignee → set slaRespondedAt (if not already set) — assignment counts as response
|
||||
//
|
||||
// Audit-logged (GoBD).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateTicketSchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const existing = await db.ticket.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'ticket not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// SLA resolution timestamp.
|
||||
if (
|
||||
(data.status === 'resolved' || data.status === 'closed') &&
|
||||
!existing.slaResolvedAt
|
||||
) {
|
||||
data.slaResolvedAt = now
|
||||
}
|
||||
|
||||
// SLA response timestamp — assignment counts as first response.
|
||||
if (
|
||||
typeof data.assignee === 'string' &&
|
||||
data.assignee.trim().length > 0 &&
|
||||
!existing.slaRespondedAt
|
||||
) {
|
||||
data.slaRespondedAt = now
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.ticket.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'ticket:update',
|
||||
entity: 'Ticket',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ticket: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/tickets/[id] — delete a ticket.
|
||||
//
|
||||
// Cascade policy: tickets with messages cannot be hard-deleted (the
|
||||
// conversation is part of the audit trail). Tickets in status 'closed' or
|
||||
// 'resolved' with NO messages can be deleted. Tickets that have been
|
||||
// escalated (escalatedToAdmin=true) cannot be deleted.
|
||||
//
|
||||
// GoBD: never silently cascade-delete ticket messages.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.ticket.findUnique({
|
||||
where: { id },
|
||||
include: { messages: { select: { id: true } } },
|
||||
})
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'ticket not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Block if escalated to admin — those records stay for compliance.
|
||||
if (existing.escalatedToAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ticket escalated to admin — cannot be deleted' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Block if ticket has messages — conversation must be retained.
|
||||
if (existing.messages.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ticket has messages — close the ticket instead of deleting it',
|
||||
messageCount: existing.messages.length,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Only allow deletion of empty (no message) tickets in resolved/closed state.
|
||||
// (Open/pending tickets with no messages were created in error and may be cleaned up.)
|
||||
await db.ticket.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'ticket:delete',
|
||||
entity: 'Ticket',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify({ deleted: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, id })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -23,18 +23,52 @@ export async function POST(
|
||||
.map((m) => `[${m.fromType.toUpperCase()}] ${m.fromName} (${new Date(m.createdAt).toLocaleString('de-DE')}):\n${m.body}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
// --- Graphify codebase context ---
|
||||
// If the ticket belongs to a company that has an instance with a built
|
||||
// graph, query the codebase for relevant code before generating the summary.
|
||||
// This gives the AI context about WHERE the issue is in the code.
|
||||
let codebaseContext = ''
|
||||
if (process.env.GRAPHIFY_URL) {
|
||||
try {
|
||||
const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify')
|
||||
const graphify = createGraphifyClientFromEnv()
|
||||
if (graphify) {
|
||||
// Find the instance linked to this ticket's company
|
||||
const instance = await db.instance.findFirst({
|
||||
where: { companyId: ticket.companyId, status: { notIn: ['deleted', 'deleting'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (instance) {
|
||||
const graphPath = graphPathForInstance(instance.id)
|
||||
// Query the graph with the ticket subject — "Where is X implemented?"
|
||||
const graphResult = await graphify.queryGraph(ticket.subject, graphPath)
|
||||
if (graphResult.nodes && graphResult.nodes.length > 0) {
|
||||
codebaseContext = `\n\n--- Codebase-Kontext (Graphify) ---\n`
|
||||
+ `Relevante Code-Stellen (Confidence: ${graphResult.confidence}):\n`
|
||||
+ graphResult.nodes.slice(0, 5).map((n) =>
|
||||
`- ${n.label} (${n.type})${n.file ? ` in ${n.file}:${n.line ?? '?'}` : ''}`
|
||||
).join('\n')
|
||||
+ `\n\nAntwort: ${graphResult.answer}`
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Graphify is optional — if it fails, continue without codebase context
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = `Du bist ein Support-Assistent für eine deutsche Software-Agentur. Fasse ein Support-Ticket prägnant zusammen (max. 4 Sätze):
|
||||
1. Was ist das Kernproblem / die Anfrage?
|
||||
2. Was wurde bisher unternommen?
|
||||
3. Was ist der aktuelle Status / nächste Schritt?
|
||||
Schreibe auf Deutsch, sachlich, keine Einleitungssätze.`
|
||||
Schreibe auf Deutsch, sachlich, keine Einleitungssätze.${codebaseContext ? '\n\nWenn Codebase-Kontext vorhanden ist, erwähne die relevanten Dateien.' : ''}`
|
||||
|
||||
const userPrompt = `Ticket: ${ticket.number} — ${ticket.subject}
|
||||
Kunde: ${ticket.companyId}
|
||||
Priorität: ${ticket.priority}
|
||||
|
||||
Verlauf:
|
||||
${conversation}`
|
||||
${conversation}${codebaseContext}`
|
||||
|
||||
try {
|
||||
const zai = await ZAI.create()
|
||||
|
||||
@@ -159,6 +159,28 @@ export async function POST(req: Request) {
|
||||
},
|
||||
})
|
||||
|
||||
// Send ticket created email
|
||||
const { sendTicketCreatedEmail } = await import('@/lib/email')
|
||||
if (company.email) {
|
||||
sendTicketCreatedEmail({
|
||||
to: company.email,
|
||||
ticketNumber: ticket.number,
|
||||
subject: ticket.subject,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Dispatch webhook event
|
||||
const { dispatchWebhook } = await import('@/lib/webhook')
|
||||
dispatchWebhook('ticket.created', {
|
||||
ticketId: ticket.id,
|
||||
number: ticket.number,
|
||||
subject: ticket.subject,
|
||||
companyId,
|
||||
priority,
|
||||
routedToAgency,
|
||||
agencyId,
|
||||
}).catch(() => {})
|
||||
|
||||
return NextResponse.json({ ticket }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// PATCH /api/aos/users/[id] — update user role (admin only).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const { role } = body as { role?: string }
|
||||
|
||||
const existing = await db.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
if (role) {
|
||||
const validRoles = ['owner', 'admin', 'sales', 'pm', 'developer', 'finance', 'readonly', 'client', 'member', 'reseller']
|
||||
if (!validRoles.includes(role)) {
|
||||
return NextResponse.json({ error: `invalid role: ${role}` }, { status: 400 })
|
||||
}
|
||||
data.role = role
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.user.update({ where: { id }, data })
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'user.update',
|
||||
entity: 'User',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ role: existing.role }),
|
||||
after: JSON.stringify({ role: updated.role }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ user: { id: updated.id, email: updated.email, role: updated.role } })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/users/[id] — hard-delete a user.
|
||||
// Guard rails:
|
||||
// - Cannot delete the env-admin (ADMIN_EMAIL). 403.
|
||||
// - Cannot delete the last owner (prevents lockout). 409.
|
||||
// - Cannot delete yourself (session check). 409.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Block deletion of the env-managed admin account.
|
||||
const adminEmail = process.env.ADMIN_EMAIL
|
||||
if (adminEmail && existing.email.toLowerCase() === adminEmail.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Der Admin-Account (Umgebungsvariable) kann nicht gelöscht werden.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Block deleting the last owner — prevents lockout.
|
||||
if (existing.role === 'owner') {
|
||||
const ownerCount = await db.user.count({ where: { role: 'owner' } })
|
||||
if (ownerCount <= 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Der letzte Owner kann nicht gelöscht werden — mindestens ein Owner muss erhalten bleiben.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent self-deletion via session check.
|
||||
const { getCurrentUser } = await import('@/lib/session')
|
||||
const me = await getCurrentUser()
|
||||
if (me?.id === id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Sie können sich nicht selbst löschen.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.user.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: me ? `user:${me.email}` : 'admin:ui',
|
||||
action: 'user.delete',
|
||||
entity: 'User',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ email: existing.email, name: existing.name, role: existing.role }),
|
||||
after: JSON.stringify({ deleted: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, id })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/users — list all users (admin management).
|
||||
// Excludes passwordHash + 2FA secrets (never sent to the client).
|
||||
export async function GET() {
|
||||
try {
|
||||
const users = await db.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
companyId: true,
|
||||
emailVerifiedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
company: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ users })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { verifyPassword } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const disableSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
// POST /api/auth/2fa/disable
|
||||
//
|
||||
// Disables 2FA for the authenticated user. Requires the current password
|
||||
// (re-authentication) so a hijacked session can't silently weaken security.
|
||||
// Clears the TOTP secret + backup codes; the user row is retained.
|
||||
//
|
||||
// Audit-logged.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = disableSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Passwort erforderlich.' }, { status: 400 })
|
||||
}
|
||||
const { password } = parsed.data
|
||||
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const full = await db.user.findUnique({ where: { id: user.id }, select: { passwordHash: true, twoFactorMethod: true } })
|
||||
if (!full?.passwordHash) {
|
||||
return NextResponse.json({ error: 'Kein Passwort gesetzt.' }, { status: 400 })
|
||||
}
|
||||
if (!verifyPassword(password, full.passwordHash)) {
|
||||
return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 })
|
||||
}
|
||||
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
twoFactorSecret: null,
|
||||
twoFactorBackup: null,
|
||||
twoFactorEnabledAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: `user:${user.email}`,
|
||||
action: 'auth.2fa.disable',
|
||||
entity: 'User',
|
||||
entityId: user.id,
|
||||
before: JSON.stringify({ method: full.twoFactorMethod }),
|
||||
after: JSON.stringify({ disabled: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, message: '2FA deaktiviert.' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import {
|
||||
verifyTotp,
|
||||
generateBackupCodes,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const enableSchema = z.object({
|
||||
method: z.enum(['totp', 'email']),
|
||||
// Required when method='totp': the secret from /2fa/setup + a code the
|
||||
// user enters from their authenticator app to prove they scanned it.
|
||||
secret: z.string().optional(),
|
||||
code: z.string().min(6).max(6).optional(),
|
||||
// Re-auth: user must confirm current password to enable 2FA (anti-session-hijack).
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
// POST /api/auth/2fa/enable
|
||||
//
|
||||
// Enables 2FA for the authenticated user. Requires the current password
|
||||
// (re-authentication) so a hijacked session can't silently lock the owner
|
||||
// out by enabling 2FA.
|
||||
//
|
||||
// method='totp' → secret + code must be present; code verified against
|
||||
// the secret (proves the user scanned the QR).
|
||||
// method='email' → no secret needed; OTP delivery happens at login time.
|
||||
//
|
||||
// On success, 8 backup codes are generated and returned ONCE (the user must
|
||||
// save them — they're stored hashed and shown only here).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = enableSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { method, secret, code, password } = parsed.data
|
||||
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Re-auth: verify current password.
|
||||
const full = await db.user.findUnique({ where: { id: user.id }, select: { passwordHash: true } })
|
||||
if (!full?.passwordHash) {
|
||||
return NextResponse.json({ error: 'Kein Passwort gesetzt.' }, { status: 400 })
|
||||
}
|
||||
const { verifyPassword } = await import('@/lib/auth')
|
||||
if (!verifyPassword(password, full.passwordHash)) {
|
||||
return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (method === 'totp') {
|
||||
if (!secret || !code) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Secret und Code aus der Authenticator-App erforderlich.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (!verifyTotp(code, secret)) {
|
||||
return NextResponse.json({ error: 'Code aus der App ist falsch — bitte erneut versuchen.' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
// Generate backup codes (only when enabling — not when switching methods
|
||||
// if already enabled, but here we regenerate for simplicity).
|
||||
const { plain: backupPlain, hashed: backupHashed } = generateBackupCodes(8)
|
||||
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: method,
|
||||
twoFactorSecret: method === 'totp' ? secret : null,
|
||||
twoFactorBackup: backupHashed,
|
||||
twoFactorEnabledAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: `user:${user.email}`,
|
||||
action: 'auth.2fa.enable',
|
||||
entity: 'User',
|
||||
entityId: user.id,
|
||||
after: JSON.stringify({ method }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
method,
|
||||
backupCodes: backupPlain,
|
||||
message: method === 'totp'
|
||||
? '2FA mit Authenticator-App aktiviert. Backup-Codes sicher speichern!'
|
||||
: '2FA per E-Mail aktiviert. Backup-Codes sicher speichern!',
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import QRCode from 'qrcode'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { generateTotpSecret, totpOtpauthUrl } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/auth/2fa/setup
|
||||
//
|
||||
// Initiates TOTP setup for the authenticated user. Generates a fresh secret
|
||||
// (NOT persisted yet — the client holds it and submits it back with a code
|
||||
// to /2fa/enable). Returns the otpauth:// URL and a PNG data-URI QR code
|
||||
// the user scans with Google Authenticator / Authy / 1Password.
|
||||
//
|
||||
// For the 'email' method there is nothing to set up — the frontend should
|
||||
// call /2fa/enable directly with method='email'.
|
||||
export async function POST() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const secret = generateTotpSecret()
|
||||
const label = `${user.email}`
|
||||
const issuer = 'AOS'
|
||||
const otpauthUrl = totpOtpauthUrl({ secret, label, issuer })
|
||||
|
||||
const qr = await QRCode.toDataURL(otpauthUrl, {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
secret,
|
||||
otpauthUrl,
|
||||
qr,
|
||||
issuer,
|
||||
label,
|
||||
// Manual entry key, grouped for readability.
|
||||
secretFormatted: secret.replace(/(.{4})/g, '$1 ').trim(),
|
||||
})
|
||||
}
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import {
|
||||
verifyPassword,
|
||||
createSessionToken,
|
||||
createLoginChallenge,
|
||||
generateEmailOtp,
|
||||
hashOtpCode,
|
||||
otpExpiry,
|
||||
otpMaxAttempts,
|
||||
} from '@/lib/auth'
|
||||
import { ensureAdminUser } from '@/lib/ensure-admin'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email().max(200).toLowerCase(),
|
||||
password: z.string().min(1).max(200),
|
||||
redirect: z.string().optional(),
|
||||
})
|
||||
|
||||
// POST /api/auth/login
|
||||
//
|
||||
// Step 1 of the login flow. Verifies email + password, then either:
|
||||
// - 2FA OFF → sets the session cookie immediately, returns { ok, redirect }
|
||||
// - 2FA ON → issues a short-lived signed challenge and returns
|
||||
// { twoFactorRequired: true, method, challenge }. For the
|
||||
// 'email' method an OTP is also sent to the user's inbox.
|
||||
//
|
||||
// The challenge is HMAC-signed (5-min TTL) and carries the userId + method.
|
||||
// Step 2 is POST /api/auth/login/verify with { challenge, code }.
|
||||
//
|
||||
// Anti-enumeration: unknown emails / wrong passwords both return the same
|
||||
// generic "Ungültige Anmeldedaten" so attackers can't probe which emails
|
||||
// exist. Rate limiting is handled by the global middleware (120 req/min/IP).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Ensure the admin account exists (env-managed) so it's always loggable,
|
||||
// even on a fresh deploy with an empty database.
|
||||
await ensureAdminUser()
|
||||
|
||||
const parsed = loginSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 400 })
|
||||
}
|
||||
const { email, password, redirect } = parsed.data
|
||||
|
||||
const user = await db.user.findUnique({ where: { email } })
|
||||
|
||||
// Constant-ish path: if user is null, still run a hash verify against a
|
||||
// dummy hash so timing doesn't reveal whether the email exists.
|
||||
const dummyHash = '$scrypt$dummy'
|
||||
const passwordOk = user?.passwordHash
|
||||
? verifyPassword(password, user.passwordHash)
|
||||
: (verifyPassword(password, dummyHash), false)
|
||||
|
||||
if (!user || !user.passwordHash || !passwordOk) {
|
||||
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 401 })
|
||||
}
|
||||
|
||||
// --- 2FA branch ---
|
||||
if (user.twoFactorEnabled && user.twoFactorMethod) {
|
||||
const method = user.twoFactorMethod
|
||||
const challenge = createLoginChallenge(user.id, method)
|
||||
|
||||
if (method === 'email') {
|
||||
// Generate + store an OTP, email it to the user.
|
||||
const code = generateEmailOtp()
|
||||
await db.otpCode.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
codeHash: hashOtpCode(code),
|
||||
purpose: 'login',
|
||||
expiresAt: otpExpiry(),
|
||||
},
|
||||
})
|
||||
const { sendEmail } = await import('@/lib/email')
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'AOS — Anmeldecode',
|
||||
text: `Ihr Anmeldecode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.\n\nWenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.`,
|
||||
html: `<p>Ihr Anmeldecode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p style="color:#888;font-size:12px;">Wenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.</p>`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
twoFactorRequired: true,
|
||||
method,
|
||||
challenge,
|
||||
// mask email for display
|
||||
maskedEmail: maskEmail(user.email),
|
||||
message: method === 'email'
|
||||
? `Ein 6-stelliger Code wurde an ${maskEmail(user.email)} gesendet.`
|
||||
: 'Bitte den Code aus Ihrer Authenticator-App eingeben.',
|
||||
})
|
||||
}
|
||||
|
||||
// --- No 2FA → set session immediately ---
|
||||
await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } })
|
||||
|
||||
const token = createSessionToken({ sub: user.id, email: user.email, role: user.role })
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
redirect: redirect || '/',
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
||||
})
|
||||
res.cookies.set('aos-session', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
return res
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split('@')
|
||||
if (!domain) return email
|
||||
const visible = local.slice(0, Math.max(1, Math.min(2, local.length - 1)))
|
||||
return `${visible}${'•'.repeat(Math.max(2, local.length - 2))}@${domain}`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import {
|
||||
verifyLoginChallenge,
|
||||
createSessionToken,
|
||||
verifyTotp,
|
||||
verifyOtpCode,
|
||||
consumeBackupCode,
|
||||
hashOtpCode,
|
||||
otpMaxAttempts,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const verifySchema = z.object({
|
||||
challenge: z.string().min(1),
|
||||
code: z.string().min(4).max(40),
|
||||
redirect: z.string().optional(),
|
||||
})
|
||||
|
||||
// POST /api/auth/login/verify
|
||||
//
|
||||
// Step 2 of the login flow. Validates the challenge token (issued by step 1)
|
||||
// and verifies the OTP code:
|
||||
// - method='totp' → code verified against the stored TOTP secret
|
||||
// - method='email' → code verified against a stored OtpCode row (hashed)
|
||||
// - 10-char backup codes are also accepted as a recovery path.
|
||||
//
|
||||
// On success: sets the session cookie + updates lastLoginAt. On failure:
|
||||
// 401 with a remaining-attempts hint (after 5 wrong attempts the latest
|
||||
// code is invalidated and the user must restart login).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = verifySchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Code fehlt oder ist ungültig.' }, { status: 400 })
|
||||
}
|
||||
const { challenge, code, redirect } = parsed.data
|
||||
|
||||
const ch = verifyLoginChallenge(challenge)
|
||||
if (!ch) {
|
||||
return NextResponse.json({ error: 'Sitzung abgelaufen — bitte erneut anmelden.' }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: ch.sub },
|
||||
select: { id: true, email: true, name: true, role: true, twoFactorMethod: true, twoFactorSecret: true, twoFactorBackup: true, twoFactorEnabled: true },
|
||||
})
|
||||
if (!user || !user.twoFactorEnabled) {
|
||||
return NextResponse.json({ error: '2FA nicht aktiv — bitte erneut anmelden.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedCode = code.trim().replace(/\s/g, '')
|
||||
|
||||
// --- Try TOTP ---
|
||||
if (ch.method === 'totp' && user.twoFactorSecret) {
|
||||
if (verifyTotp(normalizedCode, user.twoFactorSecret)) {
|
||||
return await finishLogin(user, redirect)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Try email OTP (look up the latest unused code for this user+purpose) ---
|
||||
if (ch.method === 'email') {
|
||||
const otp = await db.otpCode.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
purpose: 'login',
|
||||
consumedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!otp) {
|
||||
return NextResponse.json({ error: 'Kein gültiger Code. Bitte erneut anmelden.' }, { status: 401 })
|
||||
}
|
||||
if (otp.attempts >= otpMaxAttempts()) {
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
|
||||
return NextResponse.json({ error: 'Zu viele Versuche — bitte erneut anmelden.' }, { status: 429 })
|
||||
}
|
||||
if (verifyOtpCode(normalizedCode, otp.codeHash)) {
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
|
||||
return await finishLogin(user, redirect)
|
||||
}
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { attempts: { increment: 1 } } })
|
||||
const remaining = otpMaxAttempts() - (otp.attempts + 1)
|
||||
return NextResponse.json(
|
||||
{ error: 'Falscher Code.', remainingAttempts: Math.max(0, remaining) },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// --- Try backup code (recovery, 10 chars, format XXXX-XXXX-XX) ---
|
||||
if (user.twoFactorBackup && normalizedCode.length >= 8) {
|
||||
const { match, remainingHashed } = consumeBackupCode(normalizedCode, user.twoFactorBackup)
|
||||
if (match) {
|
||||
await db.user.update({ where: { id: user.id }, data: { twoFactorBackup: remainingHashed } })
|
||||
return await finishLogin(user, redirect)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Ungültiger Code.' }, { status: 401 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function finishLogin(
|
||||
user: { id: string; email: string; name: string; role: string },
|
||||
redirect?: string
|
||||
) {
|
||||
await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } })
|
||||
const token = createSessionToken({ sub: user.id, email: user.email, role: user.role })
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
redirect: redirect || '/',
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
||||
})
|
||||
res.cookies.set('aos-session', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
return res
|
||||
}
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/auth/logout
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ ok: true })
|
||||
res.cookies.delete('aos-session')
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/auth/me
|
||||
// Returns the currently authenticated user (from the session cookie) or 401.
|
||||
// Used by the frontend to hydrate the current session + show 2FA status.
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
twoFactorMethod: user.twoFactorMethod,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { hashPassword, validatePasswordStrength } from '@/lib/auth'
|
||||
import { ensureAdminUser, isAdminEmail } from '@/lib/ensure-admin'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email().max(200).toLowerCase(),
|
||||
password: z.string().min(8).max(200),
|
||||
accountType: z.enum(['user', 'reseller']).default('user'),
|
||||
// Reseller-only: the company they're registering as. Required when
|
||||
// accountType='reseller'. For 'user' this is ignored.
|
||||
companyName: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
// POST /api/auth/register
|
||||
//
|
||||
// Self-service registration for normal users + resellers. The platform owner
|
||||
// is NOT created here — the owner account is bootstrapped from env
|
||||
// (ADMIN_EMAIL + ADMIN_PASSWORD) by ensureAdminUser(), which we call first so
|
||||
// the admin email is reserved before accepting signups.
|
||||
//
|
||||
// accountType:
|
||||
// 'user' → role='client', no company created (internal/staff-like).
|
||||
// 'reseller' → role='reseller' + a new Company with isAgency=true,
|
||||
// agencyStatus='pending' + an AgencyApplication record.
|
||||
// Admin must approve before the reseller can onboard clients.
|
||||
//
|
||||
// Email verification: a verification code is emailed if SMTP is configured.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Ensure the admin account exists so the reserved email is taken.
|
||||
await ensureAdminUser()
|
||||
|
||||
const parsed = registerSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { name, email, password, accountType, companyName } = parsed.data
|
||||
|
||||
// The admin email is reserved — never registerable here.
|
||||
if (isAdminEmail(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Diese E-Mail ist reserviert. Bitte melden Sie sich direkt an.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const pwError = validatePasswordStrength(password)
|
||||
if (pwError) {
|
||||
return NextResponse.json({ error: pwError }, { status: 400 })
|
||||
}
|
||||
|
||||
if (accountType === 'reseller' && !companyName?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Für Reseller-Registrierung ist ein Firmenname erforderlich.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check for an existing user with this email that already has a password
|
||||
// (claimed) — that's a real conflict.
|
||||
const existing = await db.user.findUnique({ where: { email } })
|
||||
if (existing?.passwordHash) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Für diese E-Mail existiert bereits ein Konto. Bitte anmelden.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(password)
|
||||
const role = accountType === 'reseller' ? 'reseller' : 'client'
|
||||
|
||||
// For resellers: create the Company + AgencyApplication up front, then link
|
||||
// the new User to it. We do this in a transaction so a partial failure
|
||||
// doesn't leave orphan records.
|
||||
let companyId: string | null = null
|
||||
if (accountType === 'reseller') {
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
const company = await tx.company.create({
|
||||
data: {
|
||||
name: companyName!.trim(),
|
||||
email,
|
||||
source: 'self-signup',
|
||||
isAgency: true,
|
||||
agencyStatus: 'pending',
|
||||
agencyAppliedAt: new Date(),
|
||||
},
|
||||
})
|
||||
await tx.agencyApplication.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
status: 'pending',
|
||||
},
|
||||
})
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actor: `signup:${email}`,
|
||||
action: 'reseller.signup',
|
||||
entity: 'Company',
|
||||
entityId: company.id,
|
||||
after: JSON.stringify({ name: company.name, email, status: 'pending' }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
return company.id
|
||||
})
|
||||
companyId = result
|
||||
}
|
||||
|
||||
let user
|
||||
if (existing) {
|
||||
// Claim a seeded (password-less) user — keep id, set role + password.
|
||||
user = await db.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { name, passwordHash, role, companyId },
|
||||
})
|
||||
} else {
|
||||
user = await db.user.create({
|
||||
data: { name, email, passwordHash, role, companyId },
|
||||
})
|
||||
}
|
||||
|
||||
// Send a verification email (best-effort; not blocking).
|
||||
const { sendEmail } = await import('@/lib/email')
|
||||
const { generateEmailOtp, hashOtpCode, otpExpiry } = await import('@/lib/auth')
|
||||
const code = generateEmailOtp()
|
||||
await db.otpCode.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
codeHash: hashOtpCode(code),
|
||||
purpose: 'email-verify',
|
||||
expiresAt: otpExpiry(),
|
||||
},
|
||||
})
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'E-Mail bestätigen — AOS',
|
||||
text: `Willkommen bei AOS!\n\nIhr Bestätigungscode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.\n\nMit freundlichen Grüßen\nDFM Solutions`,
|
||||
html: `<h2>Willkommen bei AOS!</h2><p>Ihr Bestätigungscode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p>Mit freundlichen Grüßen<br>DFM Solutions</p>`,
|
||||
}).catch(() => {})
|
||||
|
||||
// Notify the platform owner about new reseller signups (best-effort).
|
||||
if (accountType === 'reseller') {
|
||||
const adminEmail = process.env.ADMIN_EMAIL
|
||||
if (adminEmail) {
|
||||
await sendEmail({
|
||||
to: adminEmail,
|
||||
subject: `Neue Reseller-Bewerbung — ${companyName}`,
|
||||
text: `Eine neue Reseller-Bewerbung ist eingegangen.\n\nFirma: ${companyName}\nE-Mail: ${email}\n\nBitte im Admin-Bereich prüfen und genehmigen.`,
|
||||
html: `<p>Eine neue Reseller-Bewerbung ist eingegangen.</p><p><strong>Firma:</strong> ${companyName}<br><strong>E-Mail:</strong> ${email}</p><p>Bitte im Admin-Bereich prüfen und genehmigen.</p>`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: accountType === 'reseller'
|
||||
? 'Reseller-Konto erstellt. Nach Prüfung durch den Administrator erhalten Sie Zugang.'
|
||||
: 'Konto erstellt. Bitte melden Sie sich an.',
|
||||
role,
|
||||
accountType,
|
||||
emailVerificationSent: true,
|
||||
...(accountType === 'reseller' ? { resellerStatus: 'pending' } : {}),
|
||||
}, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import {
|
||||
verifyLoginChallenge,
|
||||
generateEmailOtp,
|
||||
hashOtpCode,
|
||||
otpExpiry,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const schema = z.object({
|
||||
challenge: z.string().min(1),
|
||||
})
|
||||
|
||||
// POST /api/auth/resend-otp
|
||||
//
|
||||
// Re-issues an email OTP for an in-progress 2FA login. The challenge token
|
||||
// from step 1 is required — this ensures only users who already proved
|
||||
// their password can request a new code (no anonymous spam).
|
||||
//
|
||||
// Cooldown: at most 1 resend per 30s (enforced by checking the latest code's
|
||||
// createdAt). Prevents email bombing.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = schema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Ungültige Anfrage' }, { status: 400 })
|
||||
}
|
||||
const { challenge } = parsed.data
|
||||
|
||||
const ch = verifyLoginChallenge(challenge)
|
||||
if (!ch || ch.method !== 'email') {
|
||||
return NextResponse.json({ error: 'Ungültige oder abgelaufene Sitzung.' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Cooldown: reject if the latest code is < 30s old.
|
||||
const latest = await db.otpCode.findFirst({
|
||||
where: { userId: ch.sub, purpose: 'login' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (latest && Date.now() - latest.createdAt.getTime() < 30_000) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bitte 30 Sekunden warten, bevor Sie einen neuen Code anfordern.', cooldown: true },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
const code = generateEmailOtp()
|
||||
await db.otpCode.create({
|
||||
data: {
|
||||
userId: ch.sub,
|
||||
codeHash: hashOtpCode(code),
|
||||
purpose: 'login',
|
||||
expiresAt: otpExpiry(),
|
||||
},
|
||||
})
|
||||
|
||||
const user = await db.user.findUnique({ where: { id: ch.sub }, select: { email: true } })
|
||||
if (user) {
|
||||
const { sendEmail } = await import('@/lib/email')
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'AOS — Neuer Anmeldecode',
|
||||
text: `Ihr neuer Anmeldecode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.`,
|
||||
html: `<p>Ihr neuer Anmeldecode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p>`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, message: 'Neuer Code gesendet.' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { verifyOtpCode, otpMaxAttempts } from '@/lib/auth'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string().regex(/^\d{6}$/, 'Code muss 6 Ziffern sein.'),
|
||||
})
|
||||
|
||||
// POST /api/auth/verify-email
|
||||
//
|
||||
// Verifies the email ownership OTP sent at registration. Marks the user's
|
||||
// emailVerifiedAt. Can be called by:
|
||||
// - an authenticated user verifying their own email, OR
|
||||
// - an unauthenticated user with just the code (if they haven't logged in
|
||||
// yet). In that case we look up by the most recent unconsumed code.
|
||||
//
|
||||
// Codes are single-use, 5-min TTL, max 5 attempts.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = schema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Ungültiger Code.' }, { status: 400 })
|
||||
}
|
||||
const { code } = parsed.data
|
||||
|
||||
// Try the authenticated user first; fall back to the latest unconsumed code.
|
||||
const me = await getCurrentUser()
|
||||
let userId = me?.id
|
||||
|
||||
let otp
|
||||
if (userId) {
|
||||
otp = await db.otpCode.findFirst({
|
||||
where: { userId, purpose: 'email-verify', consumedAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
} else {
|
||||
// Find the most recent unconsumed email-verify code matching this code.
|
||||
// This is a single-tenant tool with low volume — acceptable.
|
||||
const candidates = await db.otpCode.findMany({
|
||||
where: { purpose: 'email-verify', consumedAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
})
|
||||
otp = candidates.find((c) => verifyOtpCode(code, c.codeHash)) ?? null
|
||||
if (otp) userId = otp.userId
|
||||
}
|
||||
|
||||
if (!otp || !userId) {
|
||||
return NextResponse.json({ error: 'Kein gültiger Code gefunden — bitte neu anfordern.' }, { status: 404 })
|
||||
}
|
||||
if (otp.attempts >= otpMaxAttempts()) {
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
|
||||
return NextResponse.json({ error: 'Zu viele Versuche — bitte neuen Code anfordern.' }, { status: 429 })
|
||||
}
|
||||
|
||||
if (!verifyOtpCode(code, otp.codeHash)) {
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { attempts: { increment: 1 } } })
|
||||
const remaining = otpMaxAttempts() - (otp.attempts + 1)
|
||||
return NextResponse.json(
|
||||
{ error: 'Falscher Code.', remainingAttempts: Math.max(0, remaining) },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
|
||||
await db.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } })
|
||||
|
||||
return NextResponse.json({ ok: true, message: 'E-Mail bestätigt.' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -901,6 +901,107 @@ const TOOLS: McpTool[] = [
|
||||
return { number, type: args.type, amountCents: signedAmount, balanceCents: newBalance }
|
||||
},
|
||||
},
|
||||
// --- Graphify tools (tenant codebase intelligence) ---
|
||||
// These tools query the Graphify MCP HTTP service to answer questions about
|
||||
// a tenant instance's codebase. Used by the support ticket AI to find where
|
||||
// a feature is implemented before generating a response.
|
||||
{
|
||||
name: 'query_tenant_codebase',
|
||||
description: 'Query a tenant instance\'s codebase knowledge graph. Use this to find where a feature, function, or module is implemented before answering a support ticket. Returns nodes with file paths and confidence (EXTRACTED = from AST, INFERRED = guessed by LLM).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
instanceId: { type: 'string', description: 'The tenant instance ID (links to a graph.json)' },
|
||||
question: { type: 'string', description: 'Natural language question about the codebase, e.g. "Where is the payment checkout implemented?"' },
|
||||
},
|
||||
required: ['instanceId', 'question'],
|
||||
},
|
||||
requiredScopes: ['support:read', 'mcp:read'],
|
||||
handler: async (args) => {
|
||||
const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify')
|
||||
const client = createGraphifyClientFromEnv()
|
||||
if (!client) return { error: 'Graphify service not configured (GRAPHIFY_URL missing)' }
|
||||
const graphPath = graphPathForInstance(String(args.instanceId))
|
||||
try {
|
||||
const result = await client.queryGraph(String(args.question), graphPath)
|
||||
return result
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'graphify query failed' }
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_tenant_god_nodes',
|
||||
description: 'Get the "god nodes" (highly-connected hubs) in a tenant\'s codebase. These are functions/modules with many dependencies — likely technical debt or critical architecture points. Useful for code review and refactoring priorities.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
instanceId: { type: 'string', description: 'The tenant instance ID' },
|
||||
},
|
||||
required: ['instanceId'],
|
||||
},
|
||||
requiredScopes: ['support:read', 'mcp:read'],
|
||||
handler: async (args) => {
|
||||
const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify')
|
||||
const client = createGraphifyClientFromEnv()
|
||||
if (!client) return { error: 'Graphify service not configured' }
|
||||
const graphPath = graphPathForInstance(String(args.instanceId))
|
||||
try {
|
||||
const gods = await client.getGodNodes(graphPath)
|
||||
return { god_nodes: gods, count: gods.length }
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'failed' }
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_tenant_graph_stats',
|
||||
description: 'Get statistics about a tenant\'s codebase knowledge graph: node count, edge count, community count, god node count, and edge confidence breakdown (EXTRACTED vs INFERRED).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
instanceId: { type: 'string', description: 'The tenant instance ID' },
|
||||
},
|
||||
required: ['instanceId'],
|
||||
},
|
||||
requiredScopes: ['support:read', 'mcp:read'],
|
||||
handler: async (args) => {
|
||||
const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify')
|
||||
const client = createGraphifyClientFromEnv()
|
||||
if (!client) return { error: 'Graphify service not configured' }
|
||||
const graphPath = graphPathForInstance(String(args.instanceId))
|
||||
try {
|
||||
return await client.getGraphStats(graphPath)
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'failed' }
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'find_code_path',
|
||||
description: 'Find the shortest call path between two functions/modules in a tenant\'s codebase. Useful for understanding how data flows through the application.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
instanceId: { type: 'string', description: 'The tenant instance ID' },
|
||||
source: { type: 'string', description: 'Source node ID or label' },
|
||||
target: { type: 'string', description: 'Target node ID or label' },
|
||||
},
|
||||
required: ['instanceId', 'source', 'target'],
|
||||
},
|
||||
requiredScopes: ['support:read', 'mcp:read'],
|
||||
handler: async (args) => {
|
||||
const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify')
|
||||
const client = createGraphifyClientFromEnv()
|
||||
if (!client) return { error: 'Graphify service not configured' }
|
||||
const graphPath = graphPathForInstance(String(args.instanceId))
|
||||
try {
|
||||
return await client.shortestPath(String(args.source), String(args.target), graphPath)
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'failed' }
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// JSON-RPC handler
|
||||
|
||||
Regular → Executable
File mode changed.
Executable
+251
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Lock, Mail, ShieldCheck, ArrowLeft, RotateCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type Step =
|
||||
| { kind: 'credentials' }
|
||||
| { kind: 'otp'; method: string; challenge: string; maskedEmail: string }
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect') || '/'
|
||||
const registered = searchParams.get('registered') === '1'
|
||||
|
||||
const [step, setStep] = useState<Step>({ kind: 'credentials' })
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [resendCooldown, setResendCooldown] = useState(false)
|
||||
|
||||
// --- Step 1: email + password ---
|
||||
const submitCredentials = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email || !password) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.toLowerCase(), password, redirect }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Login fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
if (data.twoFactorRequired) {
|
||||
setStep({
|
||||
kind: 'otp',
|
||||
method: data.method,
|
||||
challenge: data.challenge,
|
||||
maskedEmail: data.maskedEmail,
|
||||
})
|
||||
if (data.message) toast.info(data.message)
|
||||
} else {
|
||||
router.push(data.redirect || '/')
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler — bitte erneut versuchen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 2: OTP code ---
|
||||
const submitOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (step.kind !== 'otp' || !code) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/login/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ challenge: step.challenge, code, redirect }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Code ungültig')
|
||||
if (data.remainingAttempts !== undefined) {
|
||||
setError(`${data.error || 'Code ungültig'} — ${data.remainingAttempts} Versuche übrig.`)
|
||||
}
|
||||
return
|
||||
}
|
||||
router.push(data.redirect || '/')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Netzwerkfehler — bitte erneut versuchen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resendOtp = async () => {
|
||||
if (step.kind !== 'otp') return
|
||||
setResendCooldown(true)
|
||||
const t = toast.loading('Neuen Code anfordern…')
|
||||
try {
|
||||
const res = await fetch('/api/auth/resend-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ challenge: step.challenge }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Fehlgeschlagen')
|
||||
toast.success('Neuer Code gesendet.', { id: t })
|
||||
// 30s cooldown to match backend.
|
||||
setTimeout(() => setResendCooldown(false), 30_000)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
setResendCooldown(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (step.kind === 'otp') {
|
||||
return (
|
||||
<form onSubmit={submitOtp} className="space-y-4">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||||
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
{step.method === 'totp'
|
||||
? '2-Faktor-Authentifizierung: Code aus Ihrer Authenticator-App eingeben.'
|
||||
: `Ein 6-stelliger Code wurde an ${step.maskedEmail} gesendet.`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">
|
||||
{step.method === 'totp' ? 'Authenticator-Code' : 'Bestätigungscode'}
|
||||
</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, step.method === 'totp' ? 6 : 6))}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="text-center text-lg tracking-[0.4em]"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<Button type="submit" className="w-full gap-2" disabled={loading || code.length < 4}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Bestätigen
|
||||
</Button>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep({ kind: 'credentials' }); setCode(''); setError('') }}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" /> Zurück
|
||||
</button>
|
||||
{step.method === 'email' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resendOtp}
|
||||
disabled={resendCooldown}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<RotateCw className="size-3" /> {resendCooldown ? 'Code gesendet…' : 'Neuen Code'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Backup-Code vergessen? Geben Sie einen 10-stelligen Backup-Code ein (Format: XXXX-XXXX-XX).
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submitCredentials} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@firma.de"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
{registered && (
|
||||
<p className="rounded-md border border-foreground/20 bg-foreground/5 p-2 text-center text-xs text-muted-foreground">
|
||||
Konto erstellt. Ein Bestätigungscode wurde an Ihre E-Mail gesendet — bitte anmelden.
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full gap-2" disabled={loading || !email || !password}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Anmelden
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Noch kein Konto?{' '}
|
||||
<Link href="/register" className="font-medium text-foreground hover:underline">
|
||||
Konto erstellen
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-xl bg-foreground text-background">
|
||||
<Lock className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">AOS Login</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agency Operating System — dfmpanel.eu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Laden…</div>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Geschützter Bereich — nur für autorisierte Benutzer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Lock, Mail, User, ShieldCheck, Building2, Briefcase } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AccountType = 'user' | 'reseller'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [accountType, setAccountType] = useState<AccountType>('user')
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [companyName, setCompanyName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const pwRules = [
|
||||
{ ok: password.length >= 8, label: 'Mind. 8 Zeichen' },
|
||||
{ ok: /[a-z]/.test(password), label: 'Kleinbuchstabe' },
|
||||
{ ok: /[A-Z]/.test(password), label: 'Großbuchstabe' },
|
||||
{ ok: /[0-9]/.test(password), label: 'Zahl' },
|
||||
]
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name || !email || !password) return
|
||||
if (accountType === 'reseller' && !companyName.trim()) {
|
||||
setError('Für Reseller-Registrierung ist ein Firmenname erforderlich.')
|
||||
return
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError('Passwörter stimmen nicht überein.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password,
|
||||
accountType,
|
||||
...(accountType === 'reseller' ? { companyName: companyName.trim() } : {}),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Registrierung fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
// Registration successful → go to login.
|
||||
router.push('/login?registered=1')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Netzwerkfehler — bitte erneut versuchen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-xl bg-foreground text-background">
|
||||
<User className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Konto erstellen</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zugang zum AOS — dfmpanel.eu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account type selector */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccountType('user')}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 rounded-lg border p-3 text-center transition',
|
||||
accountType === 'user'
|
||||
? 'border-foreground bg-foreground/5'
|
||||
: 'border-border hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<User className={cn('size-5', accountType === 'user' ? 'text-foreground' : 'text-muted-foreground')} />
|
||||
<span className={cn('text-xs font-medium', accountType === 'user' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
Benutzer
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccountType('reseller')}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 rounded-lg border p-3 text-center transition',
|
||||
accountType === 'reseller'
|
||||
? 'border-foreground bg-foreground/5'
|
||||
: 'border-border hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<Building2 className={cn('size-5', accountType === 'reseller' ? 'text-foreground' : 'text-muted-foreground')} />
|
||||
<span className={cn('text-xs font-medium', accountType === 'reseller' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
Reseller
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{accountType === 'reseller' && (
|
||||
<p className="flex items-start gap-1.5 rounded-md border border-border bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
|
||||
<Briefcase className="mt-0.5 size-3 shrink-0" />
|
||||
<span>
|
||||
Reseller können eigene Kunden anlegen und lizenzieren. Nach der Registrierung prüft der Administrator Ihre Bewerbung — Sie erhalten Zugang nach Freigabe.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{accountType === 'reseller' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyName">Firmenname</Label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="companyName"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Muster Agentur GmbH"
|
||||
autoComplete="organization"
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{accountType === 'reseller' ? 'Ansprechpartner' : 'Name'}</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Max Mustermann"
|
||||
autoComplete="name"
|
||||
autoFocus={accountType !== 'reseller'}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@firma.de"
|
||||
autoComplete="email"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{password && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{pwRules.map((r) => (
|
||||
<span
|
||||
key={r.label}
|
||||
className={
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ' +
|
||||
(r.ok
|
||||
? 'bg-foreground/10 text-foreground'
|
||||
: 'bg-muted text-muted-foreground')
|
||||
}
|
||||
>
|
||||
{r.ok ? <ShieldCheck className="size-2.5" /> : null}
|
||||
{r.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm">Passwort wiederholen</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full gap-2"
|
||||
disabled={loading || !name || !email || !password || !confirm || !pwRules.every((r) => r.ok) || (accountType === 'reseller' && !companyName.trim())}
|
||||
>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
{accountType === 'reseller' ? 'Reseller-Konto erstellen' : 'Konto erstellen'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Bereits ein Konto?{' '}
|
||||
<Link href="/login" className="font-medium text-foreground hover:underline">
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -19,7 +19,9 @@ import { TimesheetView } from './views/timesheet'
|
||||
import { SupportView } from './views/support'
|
||||
import { LicensesView } from './views/licenses'
|
||||
import { AgencyView } from './views/agency'
|
||||
import { ResellerPortalView } from './views/reseller-portal'
|
||||
import { InstancesView } from './views/instances'
|
||||
import { ReposView } from './views/repos'
|
||||
import { ContractsView } from './views/contracts'
|
||||
import { ExpensesView } from './views/expenses'
|
||||
import { FinanceView } from './views/finance'
|
||||
@@ -97,7 +99,9 @@ export function AppShell() {
|
||||
{view === 'support' && <SupportView />}
|
||||
{view === 'licenses' && <LicensesView />}
|
||||
{view === 'agency' && <AgencyView />}
|
||||
{view === 'reseller_portal' && <ResellerPortalView />}
|
||||
{view === 'instances' && <InstancesView />}
|
||||
{view === 'repos' && <ReposView />}
|
||||
{view === 'contracts' && <ContractsView />}
|
||||
{view === 'expenses' && <ExpensesView />}
|
||||
{view === 'finance' && <FinanceView />}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
CalendarDays,
|
||||
Database,
|
||||
Server,
|
||||
Store,
|
||||
GitBranch,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import {
|
||||
@@ -51,7 +53,9 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
|
||||
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
|
||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
|
||||
{ id: 'agency', label: 'Reseller', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
|
||||
{ id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, hint: 'Selbstbedienung: Kunden, Lizenzen, Tickets' },
|
||||
{ id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' },
|
||||
{ id: 'repos', label: 'Repos', icon: GitBranch, hint: 'Gitea-Repository-Verwaltung' },
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2, X, CheckSquare } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function BulkActionBar({
|
||||
count,
|
||||
entityLabel,
|
||||
onDelete,
|
||||
onClear,
|
||||
className,
|
||||
}: {
|
||||
count: number
|
||||
entityLabel: string
|
||||
onDelete: () => void
|
||||
onClear: () => void
|
||||
className?: string
|
||||
}) {
|
||||
if (count === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-lg border border-border bg-background px-4 py-2.5 shadow-lg',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<CheckSquare className="size-4 text-muted-foreground" />
|
||||
{count} {count === 1 ? entityLabel : `${entityLabel}e`} ausgewählt
|
||||
</span>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Ausgewählte löschen
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1" onClick={onClear}>
|
||||
<X className="size-3.5" />
|
||||
Auswahl aufheben
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Trash2, AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface BulkDeleteItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface BulkDeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
entityLabel: string
|
||||
entityLabelPlural?: string
|
||||
items: BulkDeleteItem[]
|
||||
endpointFor: (id: string) => string
|
||||
onDone?: () => void
|
||||
}
|
||||
|
||||
type Phase = 'confirm' | 'running' | 'done'
|
||||
|
||||
interface DeleteResult {
|
||||
item: BulkDeleteItem
|
||||
ok: boolean
|
||||
error?: string
|
||||
dependencies?: Record<string, number>
|
||||
}
|
||||
|
||||
export function BulkDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
entityLabel,
|
||||
entityLabelPlural,
|
||||
items,
|
||||
endpointFor,
|
||||
onDone,
|
||||
}: BulkDeleteDialogProps) {
|
||||
const [phase, setPhase] = useState<Phase>('confirm')
|
||||
const [results, setResults] = useState<DeleteResult[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
|
||||
const plural = entityLabelPlural ?? `${entityLabel}n`
|
||||
const total = items.length
|
||||
const succeeded = results.filter((r) => r.ok).length
|
||||
const failed = results.filter((r) => !r.ok)
|
||||
|
||||
const reset = () => {
|
||||
setPhase('confirm')
|
||||
setResults([])
|
||||
setCurrentIndex(0)
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
setPhase('running')
|
||||
setResults([])
|
||||
setCurrentIndex(0)
|
||||
const collected: DeleteResult[] = []
|
||||
const t = toast.loading(`${total} ${plural} werden gelöscht…`)
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
setCurrentIndex(i)
|
||||
try {
|
||||
const r = await fetch(endpointFor(item.id), { method: 'DELETE' })
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (r.ok) {
|
||||
collected.push({ item, ok: true })
|
||||
} else {
|
||||
collected.push({
|
||||
item,
|
||||
ok: false,
|
||||
error: data.error || `HTTP ${r.status}`,
|
||||
dependencies: data.dependencies,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
collected.push({
|
||||
item,
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'network error',
|
||||
})
|
||||
}
|
||||
setResults([...collected])
|
||||
}
|
||||
|
||||
const okCount = collected.filter((r) => r.ok).length
|
||||
const failCount = collected.length - okCount
|
||||
if (failCount === 0) {
|
||||
toast.success(`${okCount} ${plural} gelöscht`, { id: t })
|
||||
} else if (okCount === 0) {
|
||||
toast.error(`Keine ${plural} gelöscht — ${failCount} blockiert`, { id: t })
|
||||
} else {
|
||||
toast.warning(`${okCount} gelöscht, ${failCount} übersprungen`, { id: t, duration: 6000 })
|
||||
}
|
||||
|
||||
setPhase('done')
|
||||
onDone?.()
|
||||
}
|
||||
|
||||
const progress = total > 0 ? Math.round((results.length / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) reset()
|
||||
onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-muted-foreground" />
|
||||
{phase === 'confirm' && `${total} ${plural} löschen`}
|
||||
{phase === 'running' && `Lösche… (${currentIndex + 1}/${total})`}
|
||||
{phase === 'done' && `Ergebnis — ${succeeded}/${total} gelöscht`}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{phase === 'confirm' &&
|
||||
`Möchtest du wirklich ${total} ${plural} löschen? Diese Aktion kann nicht rückgängig gemacht werden. Einträge mit Abhängigkeiten werden übersprungen.`}
|
||||
{phase === 'running' && 'Bitte warten — die Einträge werden nacheinander gelöscht.'}
|
||||
{phase === 'done' && 'Überprüfe die Ergebnisse unten. Blockierte Einträge müssen zuerst bereinigt werden.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{phase === 'running' && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{results.map((r) => (
|
||||
<div
|
||||
key={r.item.id}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span className="truncate">{r.item.name}</span>
|
||||
{r.ok ? (
|
||||
<CheckCircle2 className="size-3.5 shrink-0 text-foreground" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||
<CheckCircle2 className="size-3" /> {succeeded} gelöscht
|
||||
</Badge>
|
||||
{failed.length > 0 && (
|
||||
<Badge variant="outline" className="gap-1 text-[10px] text-muted-foreground">
|
||||
<AlertTriangle className="size-3" /> {failed.length} übersprungen
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failed.length > 0 && (
|
||||
<div className="max-h-56 space-y-1.5 overflow-y-auto rounded-md border border-border p-2">
|
||||
{failed.map((r) => (
|
||||
<div key={r.item.id} className="space-y-0.5 text-xs">
|
||||
<p className="font-medium">{r.item.name}</p>
|
||||
<p className="text-muted-foreground">{r.error}</p>
|
||||
{r.dependencies && Object.keys(r.dependencies).filter((k) => r.dependencies![k] > 0).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(r.dependencies)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([k, n]) => (
|
||||
<Badge key={k} variant="outline" className="text-[9px]">
|
||||
{DEP_LABELS[k] ?? k}: {n}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
{phase === 'confirm' && (
|
||||
<>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
void run()
|
||||
}}
|
||||
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Alle löschen ({total})
|
||||
</AlertDialogAction>
|
||||
</>
|
||||
)}
|
||||
{phase === 'running' && (
|
||||
<AlertDialogAction disabled className="gap-1.5">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Wird ausgeführt…
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
{phase === 'done' && (
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
reset()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
Fertig
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
const DEP_LABELS: Record<string, string> = {
|
||||
invoices: 'Rechnungen',
|
||||
licenses: 'Lizenzen',
|
||||
deals: 'Deals',
|
||||
projects: 'Projekte',
|
||||
contracts: 'Verträge',
|
||||
expenses: 'Ausgaben',
|
||||
tickets: 'Tickets',
|
||||
instances: 'Instanzen',
|
||||
leads: 'Leads',
|
||||
persons: 'Ansprechpartner',
|
||||
agencyClients: 'Agentur-Kunden',
|
||||
tasks: 'Aufgaben',
|
||||
timeEntries: 'Zeiteinträge',
|
||||
approvals: 'Freigaben',
|
||||
activations: 'Aktivierungen',
|
||||
modules: 'Module',
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// Shape shared between the table row and the edit form. Optional fields
|
||||
// are stringified so the form has a single value type.
|
||||
export interface CompanyFormValue {
|
||||
id?: string
|
||||
name: string
|
||||
legalForm: string
|
||||
vatId: string
|
||||
registerCourt: string
|
||||
registerNumber: string
|
||||
website: string
|
||||
email: string
|
||||
phone: string
|
||||
city: string
|
||||
zip: string
|
||||
street: string
|
||||
tags: string
|
||||
}
|
||||
|
||||
export const EMPTY_COMPANY: CompanyFormValue = {
|
||||
name: '',
|
||||
legalForm: '',
|
||||
vatId: '',
|
||||
registerCourt: '',
|
||||
registerNumber: '',
|
||||
website: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
city: '',
|
||||
zip: '',
|
||||
street: '',
|
||||
tags: '',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
// When `company` is null, the dialog is in "create" mode.
|
||||
company?: CompanyFormValue | null
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
// Single dialog for create + edit of a CRM Company.
|
||||
// - create mode → POST /api/aos/crm/companies
|
||||
// - edit mode → PATCH /api/aos/crm/companies/[id]
|
||||
// Both write an audit entry on the server side (GoBD).
|
||||
export function CompanyFormDialog({ open, onOpenChange, company, onSaved }: Props) {
|
||||
const [form, setForm] = useState<CompanyFormValue>(EMPTY_COMPANY)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const isEdit = !!company?.id
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(company ?? EMPTY_COMPANY)
|
||||
}
|
||||
}, [open, company])
|
||||
|
||||
const update = (k: keyof CompanyFormValue, v: string) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) { toast.error('Firmenname ist Pflicht'); return }
|
||||
setSaving(true)
|
||||
const t = toast.loading(isEdit ? 'Firma wird gespeichert…' : 'Firma wird angelegt…')
|
||||
try {
|
||||
// Normalise empty strings → null on the server side (Prisma schema is
|
||||
// nullable; the PATCH handler accepts strings or null).
|
||||
const payload: Record<string, string | null> = {
|
||||
name: form.name.trim(),
|
||||
legalForm: form.legalForm.trim() || null,
|
||||
vatId: form.vatId.trim() || null,
|
||||
registerCourt: form.registerCourt.trim() || null,
|
||||
registerNumber: form.registerNumber.trim() || null,
|
||||
website: form.website.trim() || null,
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
city: form.city.trim() || null,
|
||||
zip: form.zip.trim() || null,
|
||||
street: form.street.trim() || null,
|
||||
tags: form.tags.trim() || null,
|
||||
}
|
||||
const url = isEdit ? `/api/aos/crm/companies/${company!.id}` : '/api/aos/crm/companies'
|
||||
const method = isEdit ? 'PATCH' : 'POST'
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || `${method} failed`)
|
||||
}
|
||||
toast.success(isEdit ? 'Firma aktualisiert' : 'Firma angelegt', { id: t })
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Firma bearbeiten' : 'Neue Firma'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Stammdaten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).'
|
||||
: 'Erfasse eine neue Firma im CRM. Audit-Log wird automatisch geschrieben.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Firmenname *" value={form.name} onChange={(v) => update('name', v)} className="col-span-2" />
|
||||
<Field label="Rechtsform" value={form.legalForm} onChange={(v) => update('legalForm', v)} placeholder="GmbH, UG, e.K." />
|
||||
<Field label="USt-IdNr." value={form.vatId} onChange={(v) => update('vatId', v)} placeholder="DE123456789" mono />
|
||||
<Field label="Registergericht" value={form.registerCourt} onChange={(v) => update('registerCourt', v)} placeholder="Amtsgericht Hamburg" />
|
||||
<Field label="HRB-Nummer" value={form.registerNumber} onChange={(v) => update('registerNumber', v)} mono />
|
||||
<Field label="Website" value={form.website} onChange={(v) => update('website', v)} placeholder="mueller.de" />
|
||||
<Field label="E-Mail" value={form.email} onChange={(v) => update('email', v)} placeholder="info@firma.de" />
|
||||
<Field label="Telefon" value={form.phone} onChange={(v) => update('phone', v)} placeholder="+49 40 …" />
|
||||
<Field label="Straße" value={form.street} onChange={(v) => update('street', v)} />
|
||||
<Field label="PLZ" value={form.zip} onChange={(v) => update('zip', v)} />
|
||||
<Field label="Ort" value={form.city} onChange={(v) => update('city', v)} className="col-span-2" />
|
||||
<Field label="Tags (kommasepariert)" value={form.tags} onChange={(v) => update('tags', v)} placeholder="kunde, agency, vip" className="col-span-2" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{isEdit ? 'Speichern' : 'Anlegen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
mono,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
mono?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className ?? ''}`}>
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`h-9 text-xs ${mono ? 'font-mono' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export interface DealFormValue {
|
||||
id?: string
|
||||
companyId: string
|
||||
title: string
|
||||
valueEuro: string
|
||||
stage: string
|
||||
probability: string
|
||||
expectedClose: string
|
||||
lossReason: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
companies: { id: string; name: string }[]
|
||||
// When `deal` is null the dialog is in "create" mode.
|
||||
deal?: DealFormValue | null
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const STAGES = [
|
||||
{ value: 'lead', label: 'Lead' },
|
||||
{ value: 'qualified', label: 'Qualifiziert' },
|
||||
{ value: 'proposal', label: 'Angebot' },
|
||||
{ value: 'negotiation', label: 'Verhandlung' },
|
||||
{ value: 'won', label: 'Gewonnen' },
|
||||
{ value: 'lost', label: 'Verloren' },
|
||||
]
|
||||
|
||||
const EMPTY: DealFormValue = {
|
||||
companyId: '',
|
||||
title: '',
|
||||
valueEuro: '',
|
||||
stage: 'lead',
|
||||
probability: '10',
|
||||
expectedClose: '',
|
||||
lossReason: '',
|
||||
}
|
||||
|
||||
// Single dialog for create + edit of a CRM Deal.
|
||||
// - create mode → POST /api/aos/crm/deals
|
||||
// - edit mode → PATCH /api/aos/crm/deals/[id]
|
||||
export function DealFormDialog({ open, onOpenChange, companies, deal, onSaved }: Props) {
|
||||
const [form, setForm] = useState<DealFormValue>(EMPTY)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const isEdit = !!deal?.id
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(deal ?? EMPTY)
|
||||
}
|
||||
}, [open, deal])
|
||||
|
||||
// Auto-pick the first company for create mode if none selected.
|
||||
useEffect(() => {
|
||||
if (open && !isEdit && !form.companyId && companies[0]) {
|
||||
setForm((f) => ({ ...f, companyId: companies[0].id }))
|
||||
}
|
||||
}, [open, isEdit, form.companyId, companies])
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.companyId) { toast.error('Firma auswählen'); return }
|
||||
if (!form.title.trim()) { toast.error('Titel eingeben'); return }
|
||||
const valueCents = Math.round((parseFloat(form.valueEuro.replace(',', '.')) || 0) * 100)
|
||||
const probability = Math.min(100, Math.max(0, parseInt(form.probability, 10) || 0))
|
||||
const payload: Record<string, unknown> = {
|
||||
companyId: form.companyId,
|
||||
title: form.title.trim(),
|
||||
valueCents,
|
||||
stage: form.stage,
|
||||
probability,
|
||||
expectedClose: form.expectedClose || null,
|
||||
}
|
||||
if (form.stage === 'lost' && form.lossReason.trim()) {
|
||||
payload.lossReason = form.lossReason.trim()
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const t = toast.loading(isEdit ? 'Deal wird gespeichert…' : 'Deal wird angelegt…')
|
||||
try {
|
||||
const url = isEdit ? `/api/aos/crm/deals/${deal!.id}` : '/api/aos/crm/deals'
|
||||
const method = isEdit ? 'PATCH' : 'POST'
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || `${method} failed`)
|
||||
}
|
||||
toast.success(isEdit ? 'Deal aktualisiert' : 'Deal angelegt', { id: t })
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Deal bearbeiten' : 'Neuer Deal'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Deal-Daten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).'
|
||||
: 'Legt einen Deal in der Pipeline an. Audit-Log wird automatisch geschrieben.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Firma</Label>
|
||||
<Select value={form.companyId} onValueChange={(v) => setForm((f) => ({ ...f, companyId: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Titel</Label>
|
||||
<Input value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} placeholder="z.B. Website-Relaunch" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Wert (€)</Label>
|
||||
<Input value={form.valueEuro} onChange={(e) => setForm((f) => ({ ...f, valueEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Wahrscheinlichkeit (%)</Label>
|
||||
<Input value={form.probability} onChange={(e) => setForm((f) => ({ ...f, probability: e.target.value }))} placeholder="50" inputMode="numeric" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Phase</Label>
|
||||
<Select value={form.stage} onValueChange={(v) => setForm((f) => ({ ...f, stage: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STAGES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Erwarteter Abschluss</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.expectedClose ? form.expectedClose.slice(0, 10) : ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expectedClose: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.stage === 'lost' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Verlustgrund</Label>
|
||||
<Input value={form.lossReason} onChange={(e) => setForm((f) => ({ ...f, lossReason: e.target.value }))} placeholder="z.B. Preis, Konkurrenz, Timing" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{isEdit ? 'Speichern' : 'Anlegen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Loader2, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface DeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
// Human label for the entity, e.g. "Firma", "Deal".
|
||||
entityLabel: string
|
||||
// Display name shown in the body, e.g. "Müller GmbH".
|
||||
entityName: string
|
||||
// DELETE endpoint URL.
|
||||
endpoint: string
|
||||
// Called after a successful delete (usually refetch).
|
||||
onDeleted?: () => void
|
||||
// Optional description override. Default explains that this is irreversible.
|
||||
description?: string
|
||||
}
|
||||
|
||||
// Reusable confirmation dialog for delete actions.
|
||||
//
|
||||
// Talks to the standardised DELETE endpoints in /api/aos/.../[id]/route.ts.
|
||||
// Those endpoints return 409 with a `dependencies` object when the entity
|
||||
// still has related records that block deletion — we render that list
|
||||
// inside the dialog so the user knows exactly what to clean up first.
|
||||
//
|
||||
// GoBD RULE — accounting entities must NOT use this dialog:
|
||||
// Invoices, Expenses, Licenses, Kassenbuch, Belege, Buchungssätze.
|
||||
// Those are cancel/storno-only (retained indefinitely). Only non-accounting
|
||||
// entities (Company, Deal, Project, Ticket, Instance, Repo, …) may hard-delete.
|
||||
export function DeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
entityLabel,
|
||||
entityName,
|
||||
endpoint,
|
||||
onDeleted,
|
||||
description,
|
||||
}: DeleteDialogProps) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dependencies, setDependencies] = useState<Record<string, number> | null>(null)
|
||||
|
||||
const reset = () => {
|
||||
setError(null)
|
||||
setDependencies(null)
|
||||
}
|
||||
|
||||
const confirm = async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setDependencies(null)
|
||||
const t = toast.loading(`${entityLabel} wird gelöscht…`)
|
||||
try {
|
||||
const r = await fetch(endpoint, { method: 'DELETE' })
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (!r.ok) {
|
||||
// Surface dependency info if the API gave us any.
|
||||
if (data.dependencies) setDependencies(data.dependencies as Record<string, number>)
|
||||
throw new Error(data.error || `HTTP ${r.status}`)
|
||||
}
|
||||
toast.success(`${entityLabel} gelöscht`, { id: t })
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
onDeleted?.()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Löschen fehlgeschlagen', { id: t })
|
||||
setError(err instanceof Error ? err.message : 'Löschen fehlgeschlagen')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const depEntries = dependencies ? Object.entries(dependencies).filter(([, n]) => n > 0) : []
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) reset()
|
||||
onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-muted-foreground" />
|
||||
{entityLabel} löschen
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{description ?? `Möchtest du „${entityName}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-foreground" />
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-medium">Löschen nicht möglich</p>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
{depEntries.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Abhängigkeiten:</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{depEntries.map(([k, n]) => (
|
||||
<Badge key={k} variant="outline" className="text-[10px]">
|
||||
{DEP_LABELS[k] ?? k}: {n}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Lösche oder ordne diese Datensätze zuerst zu, bevor du die {entityLabel.toLowerCase()} löschst.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
void confirm()
|
||||
}}
|
||||
disabled={busy}
|
||||
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||
Löschen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// German labels for the dependency keys returned by the DELETE endpoints.
|
||||
const DEP_LABELS: Record<string, string> = {
|
||||
invoices: 'Rechnungen',
|
||||
licenses: 'Lizenzen',
|
||||
deals: 'Deals',
|
||||
projects: 'Projekte',
|
||||
contracts: 'Verträge',
|
||||
expenses: 'Ausgaben',
|
||||
tickets: 'Tickets',
|
||||
instances: 'Instanzen',
|
||||
leads: 'Leads',
|
||||
persons: 'Ansprechpartner',
|
||||
agencyClients: 'Agentur-Kunden',
|
||||
tasks: 'Aufgaben',
|
||||
timeEntries: 'Zeiteinträge',
|
||||
approvals: 'Freigaben',
|
||||
activations: 'Aktivierungen',
|
||||
modules: 'Module',
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export interface ExpenseFormValue {
|
||||
id?: string
|
||||
categoryId: string
|
||||
vendor: string
|
||||
description: string
|
||||
amountEuro: string
|
||||
taxBp: string
|
||||
date: string
|
||||
companyId: string
|
||||
projectId: string
|
||||
billable: boolean
|
||||
markupPct: string
|
||||
recurring: boolean
|
||||
recurringInterval: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
companies: { id: string; name: string }[]
|
||||
projects: { id: string; name: string }[]
|
||||
// When `expense` is null the dialog is in "create" mode.
|
||||
expense?: ExpenseFormValue | null
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'reise', label: 'Reise' },
|
||||
{ value: 'software', label: 'Software' },
|
||||
{ value: 'hardware', label: 'Hardware' },
|
||||
{ value: 'fremdleistung', label: 'Fremdleistung' },
|
||||
{ value: 'buero', label: 'Büro' },
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
]
|
||||
|
||||
const TAX_RATES = [
|
||||
{ value: '0', label: '0% (steuerfrei)' },
|
||||
{ value: '700', label: '7% (ermäßigt)' },
|
||||
{ value: '1900', label: '19% (Regelsatz)' },
|
||||
]
|
||||
|
||||
const RECURRING_INTERVALS = [
|
||||
{ value: 'monthly', label: 'Monatlich' },
|
||||
{ value: 'quarterly', label: 'Quartalsweise' },
|
||||
{ value: 'yearly', label: 'Jährlich' },
|
||||
]
|
||||
|
||||
const EMPTY: ExpenseFormValue = {
|
||||
categoryId: 'software',
|
||||
vendor: '',
|
||||
description: '',
|
||||
amountEuro: '',
|
||||
taxBp: '1900',
|
||||
date: '',
|
||||
companyId: '',
|
||||
projectId: '',
|
||||
billable: false,
|
||||
markupPct: '0',
|
||||
recurring: false,
|
||||
recurringInterval: 'monthly',
|
||||
}
|
||||
|
||||
// Single dialog for create + edit of an Expense.
|
||||
// - create mode → POST /api/aos/expenses
|
||||
// - edit mode → PATCH /api/aos/expenses/[id]
|
||||
//
|
||||
// Server computes grossCents = amountCents * (1 + taxBp/10000); the UI does
|
||||
// not need to send grossCents. On edit, if amount or tax changes the server
|
||||
// recomputes; otherwise the existing grossCents is preserved.
|
||||
export function ExpenseFormDialog({ open, onOpenChange, companies, projects, expense, onSaved }: Props) {
|
||||
const [form, setForm] = useState<ExpenseFormValue>(EMPTY)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const isEdit = !!expense?.id
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(expense ?? EMPTY)
|
||||
}
|
||||
}, [open, expense])
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.description.trim()) { toast.error('Beschreibung eingeben'); return }
|
||||
if (!form.amountEuro.trim()) { toast.error('Betrag eingeben'); return }
|
||||
const amountCents = Math.round((parseFloat(form.amountEuro.replace(',', '.')) || 0) * 100)
|
||||
if (amountCents <= 0) { toast.error('Betrag muss größer als 0 sein'); return }
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
categoryId: form.categoryId,
|
||||
vendor: form.vendor.trim() || null,
|
||||
description: form.description.trim(),
|
||||
amountCents,
|
||||
taxBp: parseInt(form.taxBp, 10) || 1900,
|
||||
date: form.date || undefined,
|
||||
companyId: form.companyId || null,
|
||||
projectId: form.projectId || null,
|
||||
billable: form.billable,
|
||||
markupPct: parseInt(form.markupPct, 10) || 0,
|
||||
recurring: form.recurring,
|
||||
recurringInterval: form.recurring ? form.recurringInterval : null,
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const t = toast.loading(isEdit ? 'Ausgabe wird gespeichert…' : 'Ausgabe wird erfasst…')
|
||||
try {
|
||||
const url = isEdit ? `/api/aos/expenses/${expense!.id}` : '/api/aos/expenses'
|
||||
const method = isEdit ? 'PATCH' : 'POST'
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || `${method} failed`)
|
||||
}
|
||||
toast.success(isEdit ? 'Ausgabe aktualisiert' : 'Ausgabe erfasst', { id: t })
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Ausgaben-Daten aktualisieren. Bei Betrags- oder Steueränderung wird der Bruttobetrag neu berechnet. Audit-Log wird automatisch geschrieben (GoBD).'
|
||||
: 'Erfasse eine neue Ausgabe. Der Bruttobetrag wird aus Nettobetrag und Steuersatz berechnet. Audit-Log wird automatisch geschrieben.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kategorie</Label>
|
||||
<Select value={form.categoryId} onValueChange={(v) => setForm((f) => ({ ...f, categoryId: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Lieferant</Label>
|
||||
<Input value={form.vendor} onChange={(e) => setForm((f) => ({ ...f, vendor: e.target.value }))} placeholder="z.B. Amazon, O2, …" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Beschreibung *</Label>
|
||||
<Textarea value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} placeholder="z.B. Adobe Creative Cloud — Jahresabo" className="min-h-[60px] text-xs" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Netto (€) *</Label>
|
||||
<Input value={form.amountEuro} onChange={(e) => setForm((f) => ({ ...f, amountEuro: e.target.value }))} placeholder="59,40" inputMode="decimal" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Steuer</Label>
|
||||
<Select value={form.taxBp} onValueChange={(v) => setForm((f) => ({ ...f, taxBp: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TAX_RATES.map((r) => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Datum</Label>
|
||||
<Input type="date" value={form.date} onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kunde (belastbar)</Label>
|
||||
<Select value={form.companyId || 'none'} onValueChange={(v) => setForm((f) => ({ ...f, companyId: v === 'none' ? '' : v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="— Eigenbeleg —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">— Eigenbeleg —</SelectItem>
|
||||
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Projekt</Label>
|
||||
<Select value={form.projectId || 'none'} onValueChange={(v) => setForm((f) => ({ ...f, projectId: v === 'none' ? '' : v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">—</SelectItem>
|
||||
{projects.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Markup %</Label>
|
||||
<Input value={form.markupPct} onChange={(e) => setForm((f) => ({ ...f, markupPct: e.target.value }))} placeholder="0" inputMode="numeric" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Wiederkehrend?</Label>
|
||||
<Select
|
||||
value={form.recurring ? 'yes' : 'no'}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, recurring: v === 'yes' }))}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="no">Nein</SelectItem>
|
||||
<SelectItem value="yes">Ja</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{form.recurring && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Intervall</Label>
|
||||
<Select value={form.recurringInterval} onValueChange={(v) => setForm((f) => ({ ...f, recurringInterval: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECURRING_INTERVALS.map((r) => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.billable}
|
||||
onChange={(e) => setForm((f) => ({ ...f, billable: e.target.checked }))}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<span>Belastbar (an Kunde weiterberechnen)</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{isEdit ? 'Speichern' : 'Erfassen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export interface ProjectFormValue {
|
||||
id?: string
|
||||
companyId: string
|
||||
name: string
|
||||
type: string
|
||||
budgetType: string
|
||||
budgetHours: string
|
||||
fixedPriceEuro: string
|
||||
startDate: string
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
companies: { id: string; name: string }[]
|
||||
// When `project` is null the dialog is in "create" mode.
|
||||
project?: ProjectFormValue | null
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const TYPES = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'shop', label: 'Shop' },
|
||||
{ value: 'app', label: 'App' },
|
||||
{ value: 'retainer', label: 'Retainer' },
|
||||
]
|
||||
|
||||
const BUDGET_TYPES = [
|
||||
{ value: 'fixed', label: 'Fixpreis' },
|
||||
{ value: 'time-materials', label: 'Time & Materials' },
|
||||
{ value: 'retainer', label: 'Retainer' },
|
||||
]
|
||||
|
||||
const EMPTY: ProjectFormValue = {
|
||||
companyId: '',
|
||||
name: '',
|
||||
type: 'website',
|
||||
budgetType: 'fixed',
|
||||
budgetHours: '',
|
||||
fixedPriceEuro: '',
|
||||
startDate: '',
|
||||
dueDate: '',
|
||||
}
|
||||
|
||||
// Single dialog for create + edit of a Project.
|
||||
// - create mode → POST /api/aos/projects
|
||||
// - edit mode → PATCH /api/aos/projects/[id]
|
||||
export function ProjectFormDialog({ open, onOpenChange, companies, project, onSaved }: Props) {
|
||||
const [form, setForm] = useState<ProjectFormValue>(EMPTY)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const isEdit = !!project?.id
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(project ?? EMPTY)
|
||||
}
|
||||
}, [open, project])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !isEdit && !form.companyId && companies[0]) {
|
||||
setForm((f) => ({ ...f, companyId: companies[0].id }))
|
||||
}
|
||||
}, [open, isEdit, form.companyId, companies])
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.companyId) { toast.error('Firma auswählen'); return }
|
||||
if (!form.name.trim()) { toast.error('Projektname eingeben'); return }
|
||||
const fixedPriceCents = form.fixedPriceEuro
|
||||
? Math.round((parseFloat(form.fixedPriceEuro.replace(',', '.')) || 0) * 100)
|
||||
: null
|
||||
const budgetHours = form.budgetHours ? parseInt(form.budgetHours, 10) || null : null
|
||||
const payload: Record<string, unknown> = {
|
||||
companyId: form.companyId,
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
budgetType: form.budgetType,
|
||||
budgetHours,
|
||||
fixedPriceCents,
|
||||
startDate: form.startDate || null,
|
||||
dueDate: form.dueDate || null,
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const t = toast.loading(isEdit ? 'Projekt wird gespeichert…' : 'Projekt wird angelegt…')
|
||||
try {
|
||||
const url = isEdit ? `/api/aos/projects/${project!.id}` : '/api/aos/projects'
|
||||
const method = isEdit ? 'PATCH' : 'POST'
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || `${method} failed`)
|
||||
}
|
||||
toast.success(isEdit ? 'Projekt aktualisiert' : 'Projekt angelegt', { id: t })
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Projekt bearbeiten' : 'Neues Projekt'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Projekt-Stammdaten aktualisieren. Audit-Log wird automatisch geschrieben (GoBD).'
|
||||
: 'Legt ein neues Projekt an. Audit-Log wird automatisch geschrieben.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Firma</Label>
|
||||
<Select value={form.companyId} onValueChange={(v) => setForm((f) => ({ ...f, companyId: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Projektname</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="z.B. Website-Relaunch" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Typ</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Budget-Typ</Label>
|
||||
<Select value={form.budgetType} onValueChange={(v) => setForm((f) => ({ ...f, budgetType: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BUDGET_TYPES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Fixpreis (€)</Label>
|
||||
<Input value={form.fixedPriceEuro} onChange={(e) => setForm((f) => ({ ...f, fixedPriceEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Budget-Stunden</Label>
|
||||
<Input value={form.budgetHours} onChange={(e) => setForm((f) => ({ ...f, budgetHours: e.target.value }))} placeholder="120" inputMode="numeric" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Startdatum</Label>
|
||||
<Input type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Fälligkeit</Label>
|
||||
<Input type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{isEdit ? 'Speichern' : 'Anlegen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export interface TicketFormValue {
|
||||
id?: string
|
||||
subject: string
|
||||
status: string
|
||||
priority: string
|
||||
categoryId: string
|
||||
assignee: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
ticket?: TicketFormValue | null
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const STATUSES = [
|
||||
{ value: 'open', label: 'Offen' },
|
||||
{ value: 'pending', label: 'Wartend' },
|
||||
{ value: 'resolved', label: 'Gelöst' },
|
||||
{ value: 'closed', label: 'Geschlossen' },
|
||||
]
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 'low', label: 'Niedrig' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'high', label: 'Hoch' },
|
||||
{ value: 'urgent', label: 'Dringend' },
|
||||
]
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'bug', label: 'Bug' },
|
||||
{ value: 'anfrage', label: 'Anfrage' },
|
||||
{ value: 'aenderung', label: 'Änderung' },
|
||||
{ value: 'konto', label: 'Konto' },
|
||||
{ value: 'rechnung', label: 'Rechnung' },
|
||||
{ value: 'hosting', label: 'Hosting' },
|
||||
]
|
||||
|
||||
const EMPTY: TicketFormValue = {
|
||||
subject: '',
|
||||
status: 'open',
|
||||
priority: 'normal',
|
||||
categoryId: 'anfrage',
|
||||
assignee: '',
|
||||
}
|
||||
|
||||
// Edit-only dialog (creation is done via the public ticket creation flow).
|
||||
// Patches via /api/aos/tickets/[id]. Audit-logged (GoBD).
|
||||
//
|
||||
// Side effects handled on the server:
|
||||
// - status='resolved'|'closed' → slaResolvedAt set
|
||||
// - first non-empty assignee → slaRespondedAt set
|
||||
export function TicketEditDialog({ open, onOpenChange, ticket, onSaved }: Props) {
|
||||
const [form, setForm] = useState<TicketFormValue>(EMPTY)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(ticket ?? EMPTY)
|
||||
}
|
||||
}, [open, ticket])
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.subject.trim()) { toast.error('Betreff eingeben'); return }
|
||||
if (!ticket?.id) { toast.error('Ticket-ID fehlt'); return }
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
subject: form.subject.trim(),
|
||||
status: form.status,
|
||||
priority: form.priority,
|
||||
categoryId: form.categoryId,
|
||||
assignee: form.assignee.trim() || null,
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const t = toast.loading('Ticket wird aktualisiert…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/tickets/${ticket.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'PATCH failed')
|
||||
}
|
||||
toast.success('Ticket aktualisiert', { id: t })
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ticket bearbeiten</DialogTitle>
|
||||
<DialogDescription>
|
||||
Betreff, Status, Priorität, Kategorie und Assignee. Audit-Log wird automatisch geschrieben (GoBD).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Betreff</Label>
|
||||
<Input value={form.subject} onChange={(e) => setForm((f) => ({ ...f, subject: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Status</Label>
|
||||
<Select value={form.status} onValueChange={(v) => setForm((f) => ({ ...f, status: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Priorität</Label>
|
||||
<Select value={form.priority} onValueChange={(v) => setForm((f) => ({ ...f, priority: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITIES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kategorie</Label>
|
||||
<Select value={form.categoryId} onValueChange={(v) => setForm((f) => ({ ...f, categoryId: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Assignee</Label>
|
||||
<Input value={form.assignee} onChange={(e) => setForm((f) => ({ ...f, assignee: e.target.value }))} placeholder="z.B. Aylin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -37,10 +37,20 @@ import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Info,
|
||||
CreditCard,
|
||||
Plus,
|
||||
Ban,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { formatEuro, formatDate, formatRelative, daysUntil } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -94,6 +104,8 @@ interface PortalData {
|
||||
licenses: {
|
||||
id: string; key: string; product: string; plan: string; status: string
|
||||
validUntil: string | null; activations: { domain: string; environment: string }[]
|
||||
netPriceCents: number; billingCycle: string; parentId: string | null
|
||||
trialEndsAt: string | null; noticePeriodDays: number; cancelledAt: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -503,6 +515,32 @@ function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => v
|
||||
}
|
||||
|
||||
function PortalInvoices({ data }: { data: PortalData }) {
|
||||
const [paying, setPaying] = useState<string | null>(null)
|
||||
const companyId = data.company.id
|
||||
|
||||
const pay = async (licenseId: string) => {
|
||||
setPaying(licenseId)
|
||||
const t = toast.loading('Stripe Checkout wird vorbereitet…')
|
||||
try {
|
||||
const r = await fetch('/api/aos/stripe/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ licenseId }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'checkout failed')
|
||||
toast.success('Weiterleitung zu Stripe…', { id: t })
|
||||
window.location.href = res.url
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Checkout fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setPaying(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Find licenses with a price > 0 that could be paid
|
||||
const payableLicenses = data.licenses.filter((l) => l.netPriceCents > 0 && l.status === 'active' && !l.parentId)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -512,6 +550,46 @@ function PortalInvoices({ data }: { data: PortalData }) {
|
||||
<p className="text-xs text-muted-foreground">Deine Rechnungen — Ansicht nur für dein Unternehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pay subscription */}
|
||||
{payableLicenses.length > 0 && (
|
||||
<Card className="border-foreground/30 bg-foreground/5">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<CreditCard className="mt-0.5 size-4 text-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Abonnement bezahlen</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{payableLicenses.length} Lizenz(en) mit offener Zahlung. Klicke auf „Jetzt zahlen", um zur Stripe-Checkout-Seite zu gelangen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{payableLicenses.map((l) => (
|
||||
<div key={l.id} className="flex items-center justify-between rounded-md border border-border p-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium">{l.product} — {l.plan}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{formatEuro(l.netPriceCents)} / {l.billingCycle === 'yearly' ? 'Jahr' : 'Monat'}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={paying === l.id}
|
||||
onClick={() => pay(l.id)}
|
||||
>
|
||||
{paying === l.id && <Loader2 className="size-3.5 animate-spin" />}
|
||||
<CreditCard className="size-3.5" />
|
||||
Jetzt zahlen
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -557,6 +635,47 @@ function PortalInvoices({ data }: { data: PortalData }) {
|
||||
}
|
||||
|
||||
function PortalTickets({ data }: { data: PortalData }) {
|
||||
const companyId = data.company.id
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [subject, setSubject] = useState('')
|
||||
const [category, setCategory] = useState('anfrage')
|
||||
const [priority, setPriority] = useState<'low' | 'normal' | 'high' | 'urgent'>('normal')
|
||||
const [message, setMessage] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const createTicket = async () => {
|
||||
if (!subject.trim() || !message.trim()) return
|
||||
setCreating(true)
|
||||
const t = toast.loading('Ticket wird erstellt…')
|
||||
try {
|
||||
const r = await fetch('/api/aos/tickets', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
companyId,
|
||||
subject: subject.trim(),
|
||||
categoryId: category,
|
||||
priority,
|
||||
message: message.trim(),
|
||||
fromName: data.company.name,
|
||||
}),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'create failed')
|
||||
toast.success(`Ticket ${res.ticket?.number ?? ''} erstellt`, { id: t })
|
||||
setShowCreate(false)
|
||||
setSubject('')
|
||||
setCategory('anfrage')
|
||||
setPriority('normal')
|
||||
setMessage('')
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -567,7 +686,9 @@ function PortalTickets({ data }: { data: PortalData }) {
|
||||
<p className="text-xs text-muted-foreground">Support-Anfragen und Konversationen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm"><LifeBuoy className="size-4" />Neues Ticket</Button>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="size-4" />Neues Ticket
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.tickets.map((t) => (
|
||||
@@ -587,11 +708,94 @@ function PortalTickets({ data }: { data: PortalData }) {
|
||||
))}
|
||||
{data.tickets.length === 0 && <p className="py-8 text-center text-xs text-muted-foreground">Keine Tickets.</p>}
|
||||
</div>
|
||||
|
||||
{/* Create Ticket Dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neues Ticket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Support-Anfrage erstellen. Dein Ticket wird an den zuständigen Reseller oder DFM Solutions weitergeleitet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Betreff</Label>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Kurze Beschreibung des Problems" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kategorie</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anfrage">Anfrage</SelectItem>
|
||||
<SelectItem value="bug">Fehler</SelectItem>
|
||||
<SelectItem value="aenderung">Änderung</SelectItem>
|
||||
<SelectItem value="rechnung">Rechnung</SelectItem>
|
||||
<SelectItem value="konto">Konto</SelectItem>
|
||||
<SelectItem value="hosting">Hosting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Priorität</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as 'low' | 'normal' | 'high' | 'urgent')}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Niedrig</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="high">Hoch</SelectItem>
|
||||
<SelectItem value="urgent">Dringend</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Nachricht</Label>
|
||||
<Textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Beschreibe dein Anliegen…" rows={4} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>Abbrechen</Button>
|
||||
<Button onClick={createTicket} disabled={creating || !subject.trim() || !message.trim()} className="gap-1.5">
|
||||
{creating && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalLicenses({ data }: { data: PortalData }) {
|
||||
const [cancelling, setCancelling] = useState<string | null>(null)
|
||||
const [showCancel, setShowCancel] = useState<string | null>(null)
|
||||
const [cancelReason, setCancelReason] = useState('')
|
||||
|
||||
const cancelLicense = async (licenseId: string) => {
|
||||
setCancelling(licenseId)
|
||||
const t = toast.loading('Kündigung wird verarbeitet…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/licenses/${licenseId}/cancel`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ reason: cancelReason || undefined }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'cancel failed')
|
||||
toast.success(res.message || 'Lizenz gekündigt', { id: t, duration: 6000 })
|
||||
setShowCancel(null)
|
||||
setCancelReason('')
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Kündigung fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setCancelling(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -616,8 +820,52 @@ function PortalLicenses({ data }: { data: PortalData }) {
|
||||
{l.validUntil && <p>Gültig bis: {formatDate(l.validUntil)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{l.status}</Badge>
|
||||
{l.status === 'active' && !l.parentId && !l.cancelledAt && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowCancel(l.id)}
|
||||
>
|
||||
<Ban className="size-3" />
|
||||
Kündigen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancel dialog */}
|
||||
{showCancel === l.id && (
|
||||
<div className="mt-3 rounded-md border border-foreground/30 bg-foreground/5 p-3">
|
||||
<p className="text-xs font-medium">Lizenz kündigen</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{l.trialEndsAt && new Date(l.trialEndsAt) > new Date()
|
||||
? 'Du befindest dich in der 14-Tage-Testphase — Kündigung wird sofort wirksam, keine Abbuchung.'
|
||||
: `Kündigungsfrist: ${l.noticePeriodDays ?? 90} Tage. Wirksam zum ${new Date(Date.now() + (l.noticePeriodDays ?? 90) * 86400000).toLocaleDateString('de-DE')}.`}
|
||||
</p>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder="Grund (optional)"
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => { setShowCancel(null); setCancelReason('') }}>Abbrechen</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={cancelling === l.id}
|
||||
onClick={() => cancelLicense(l.id)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{cancelling === l.id && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Kündigung bestätigen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -29,8 +29,11 @@ import {
|
||||
RefreshCw,
|
||||
Landmark,
|
||||
Server,
|
||||
Store,
|
||||
GitBranch,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { translate } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -69,7 +72,9 @@ const NAV: {
|
||||
items: [
|
||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
|
||||
{ id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' },
|
||||
{ id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, permission: 'admin' },
|
||||
{ id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' },
|
||||
{ id: 'repos', label: 'Repos', icon: GitBranch, permission: 'admin' },
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
||||
@@ -100,6 +105,14 @@ const NAV: {
|
||||
export function SidebarNav() {
|
||||
const { view, setView, setSidebarOpen, setCommandOpen, setPortalMode, locale } = useAos()
|
||||
const t = (k: string) => translate(k, locale)
|
||||
const { data: meData } = useFetch<{ user: { name: string; email: string; role: string } } | null>('/api/auth/me')
|
||||
const user = meData?.user
|
||||
const initials = (user?.name ?? '?')
|
||||
.split(' ')
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||
@@ -198,11 +211,11 @@ export function SidebarNav() {
|
||||
<div className="border-t border-sidebar-border px-3 py-3">
|
||||
<Button variant="ghost" className="h-auto w-full justify-start gap-3 px-2 py-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
|
||||
MB
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex flex-col items-start leading-tight">
|
||||
<span className="text-sm font-medium">Max Brandt</span>
|
||||
<span className="text-[11px] text-muted-foreground">Owner</span>
|
||||
<span className="text-sm font-medium">{user?.name ?? '—'}</span>
|
||||
<span className="text-[11px] text-muted-foreground capitalize">{user?.role ?? ''}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Menu, Search, Bell, ChevronRight } from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { LocaleSwitcher } from './locale-switcher'
|
||||
import { NotificationsBell } from './notifications-bell'
|
||||
@@ -29,7 +30,9 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
||||
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
||||
agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' },
|
||||
reseller_portal: { title: 'Reseller-Portal', crumb: 'Betrieb / Reseller-Portal' },
|
||||
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
|
||||
repos: { title: 'Repos', crumb: 'Betrieb / Repos' },
|
||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||
@@ -49,6 +52,14 @@ export function TopBar() {
|
||||
const { view, setSidebarOpen, setCommandOpen } = useAos()
|
||||
const meta = TITLES[view]
|
||||
const crumbParts = meta.crumb.split(' / ')
|
||||
const { data: meData } = useFetch<{ user: { name: string; email: string; role: string } } | null>('/api/auth/me')
|
||||
const user = meData?.user
|
||||
const initials = (user?.name ?? '?')
|
||||
.split(' ')
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md md:px-6">
|
||||
@@ -93,24 +104,33 @@ export function TopBar() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 px-1.5">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">MB</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="flex flex-col">
|
||||
<span className="text-sm font-medium">Max Brandt</span>
|
||||
<span className="text-xs text-muted-foreground">max@nordlicht.digital</span>
|
||||
<span className="text-sm font-medium">{user?.name ?? '—'}</span>
|
||||
<span className="text-xs text-muted-foreground">{user?.email ?? ''}</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="flex items-center justify-between">
|
||||
<span>Rolle</span>
|
||||
<Badge variant="outline" className="text-[10px]">Owner</Badge>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{user?.role ?? '—'}</Badge>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>Profil</DropdownMenuItem>
|
||||
<DropdownMenuItem>Sitzungen</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-muted-foreground">Abmelden</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground"
|
||||
onSelect={async (e) => {
|
||||
e.preventDefault()
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
window.location.href = '/login'
|
||||
}}
|
||||
>
|
||||
Abmelden
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Mail,
|
||||
Loader2,
|
||||
Lock,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
KeyRound,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MeResponse {
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
emailVerifiedAt: string | null
|
||||
twoFactorEnabled: boolean
|
||||
twoFactorMethod: string | null
|
||||
lastLoginAt: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type EnableStep =
|
||||
| { kind: 'choose-method' }
|
||||
| { kind: 'totp-setup'; secret: string; qr: string; otpauthUrl: string }
|
||||
| { kind: 'enter-code'; method: 'totp' | 'email'; secret?: string }
|
||||
| { kind: 'backup-codes'; codes: string[] }
|
||||
|
||||
/**
|
||||
* TwoFactorSettings — self-contained 2FA management card.
|
||||
*
|
||||
* States:
|
||||
* - 2FA OFF → "Enable" button → method chooser (Email / Authenticator)
|
||||
* - Email: re-auth (password) → enable → show backup codes
|
||||
* - TOTP: setup (QR + secret) → enter code from app → re-auth → enable → backup codes
|
||||
* - 2FA ON → show method + status → "Disable" (re-auth required)
|
||||
*
|
||||
* Fetches /api/auth/me for the current status. Uses:
|
||||
* POST /api/auth/2fa/setup → TOTP provisioning
|
||||
* POST /api/auth/2fa/enable → finalize (re-auth + verify)
|
||||
* POST /api/auth/2fa/disable → turn off (re-auth required)
|
||||
*/
|
||||
export function TwoFactorSettings() {
|
||||
const { data, loading, refetch } = useFetch<MeResponse>('/api/auth/me')
|
||||
const [enableOpen, setEnableOpen] = useState(false)
|
||||
const [disableOpen, setDisableOpen] = useState(false)
|
||||
const [step, setStep] = useState<EnableStep>({ kind: 'choose-method' })
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
const user = data?.user
|
||||
|
||||
const reset = () => {
|
||||
setStep({ kind: 'choose-method' })
|
||||
setPassword('')
|
||||
setCode('')
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
// --- TOTP setup: get QR + secret ---
|
||||
const startTotp = async () => {
|
||||
setBusy(true)
|
||||
const t = toast.loading('QR-Code wird generiert…')
|
||||
try {
|
||||
const r = await fetch('/api/auth/2fa/setup', { method: 'POST' })
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Setup fehlgeschlagen')
|
||||
setStep({ kind: 'totp-setup', secret: res.secret, qr: res.qr, otpauthUrl: res.otpauthUrl })
|
||||
toast.success('QR-Code bereit. Mit Authenticator-App scannen.', { id: t })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Enable 2FA (re-auth + verify code) ---
|
||||
const enable = async () => {
|
||||
if (!password) return
|
||||
if (step.kind === 'enter-code' && !code) return
|
||||
setBusy(true)
|
||||
const t = toast.loading('2FA wird aktiviert…')
|
||||
try {
|
||||
const method = step.kind === 'enter-code' ? step.method : 'email'
|
||||
const body: Record<string, string> = { method, password }
|
||||
if (step.kind === 'enter-code' && step.method === 'totp') {
|
||||
body.code = code
|
||||
body.secret = step.secret ?? ''
|
||||
}
|
||||
const r = await fetch('/api/auth/2fa/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Aktivierung fehlgeschlagen')
|
||||
toast.success(res.message ?? '2FA aktiviert', { id: t, duration: 6000 })
|
||||
if (res.backupCodes?.length) {
|
||||
setStep({ kind: 'backup-codes', codes: res.backupCodes })
|
||||
} else {
|
||||
setEnableOpen(false)
|
||||
reset()
|
||||
refetch()
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Disable 2FA ---
|
||||
const disable = async () => {
|
||||
if (!password) return
|
||||
setBusy(true)
|
||||
const t = toast.loading('2FA wird deaktiviert…')
|
||||
try {
|
||||
const r = await fetch('/api/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Deaktivierung fehlgeschlagen')
|
||||
toast.success(res.message ?? '2FA deaktiviert', { id: t })
|
||||
setDisableOpen(false)
|
||||
setPassword('')
|
||||
refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copy = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(label)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
toast.success(`${label} kopiert`)
|
||||
})
|
||||
}
|
||||
|
||||
const enabled = user?.twoFactorEnabled
|
||||
const method = user?.twoFactorMethod
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="size-3.5 text-muted-foreground" />
|
||||
Zwei-Faktor-Authentifizierung (2FA)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Laden…
|
||||
</div>
|
||||
) : !user ? (
|
||||
<p className="text-xs text-muted-foreground">Nicht angemeldet.</p>
|
||||
) : enabled ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1 text-[10px] text-foreground">
|
||||
<Check className="size-3" /> Aktiv
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{method === 'totp' ? (
|
||||
<span className="inline-flex items-center gap-1"><Smartphone className="size-3" /> Authenticator-App</span>
|
||||
) : method === 'email' ? (
|
||||
<span className="inline-flex items-center gap-1"><Mail className="size-3" /> E-Mail-OTP</span>
|
||||
) : method}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="gap-1.5 text-xs" onClick={() => { setDisableOpen(true); setPassword('') }}>
|
||||
<Lock className="size-3" /> Deaktivieren
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Beim Anmelden wird zusätzlich zum Passwort ein einmaliger Code benötigt. Backup-Codes wurden bei der Einrichtung angezeigt — sicher aufbewahren.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Schützen Sie Ihr Konto mit einem zweiten Faktor. Optional — kann jederzeit aktiviert oder deaktiviert werden.
|
||||
</p>
|
||||
<Button size="sm" className="gap-1.5" onClick={() => { setEnableOpen(true); reset() }}>
|
||||
<ShieldCheck className="size-3.5" /> 2FA aktivieren
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* --- Enable flow --- */}
|
||||
<Dialog open={enableOpen} onOpenChange={(o) => { setEnableOpen(o); if (!o) reset() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 text-muted-foreground" />
|
||||
2FA aktivieren
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step.kind === 'backup-codes'
|
||||
? 'Backup-Codes — sicher aufbewahren (einmalig sichtbar).'
|
||||
: 'Wählen Sie eine Methode und bestätigen Sie mit Ihrem Passwort.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step.kind === 'choose-method' && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setStep({ kind: 'enter-code', method: 'email' })}
|
||||
className="flex w-full items-center gap-3 rounded-md border border-border p-3 text-left transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
<Mail className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">E-Mail-OTP</p>
|
||||
<p className="text-[11px] text-muted-foreground">Code pro Anmeldung an Ihre E-Mail gesendet.</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={startTotp}
|
||||
className="flex w-full items-center gap-3 rounded-md border border-border p-3 text-left transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
<Smartphone className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Authenticator-App</p>
|
||||
<p className="text-[11px] text-muted-foreground">Google Authenticator, Authy, 1Password (TOTP).</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'totp-setup' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* QR code as data URI — no next/image optimization needed */}
|
||||
<img src={step.qr} alt="QR-Code für Authenticator" className="rounded-md border border-border" width={200} height={200} />
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Scannen Sie den QR-Code mit Ihrer Authenticator-App.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Oder manuell eingeben:</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md bg-muted px-2 py-1.5 font-mono text-[11px] break-all">
|
||||
{step.secret}
|
||||
</code>
|
||||
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => copy(step.secret, 'Secret')}>
|
||||
{copied === 'Secret' ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => setStep({ kind: 'enter-code', method: 'totp', secret: step.secret })}>
|
||||
Weiter — Code eingeben
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'enter-code' && (
|
||||
<div className="space-y-3">
|
||||
{step.method === 'totp' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="code" className="text-xs">Code aus der Authenticator-App</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
className="text-center text-lg tracking-[0.4em]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.method === 'email' && (
|
||||
<p className="rounded-md border border-border bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
|
||||
Beim Aktivieren wird ein Bestätigungscode an Ihre E-Mail gesendet. Geben Sie unten Ihr Passwort ein, um die Einrichtung abzuschließen.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="enable-pw" className="text-xs">Passwort zur Bestätigung</Label>
|
||||
<Input
|
||||
id="enable-pw"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setEnableOpen(false)}>Abbrechen</Button>
|
||||
<Button size="sm" disabled={busy || !password || (step.method === 'totp' && code.length < 6)} onClick={enable} className="gap-1.5">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ShieldCheck className="size-3.5" />}
|
||||
2FA aktivieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'backup-codes' && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px] text-muted-foreground">
|
||||
<p className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
<AlertTriangle className="size-3" />
|
||||
Diese Codes sind nur jetzt sichtbar.
|
||||
</p>
|
||||
<p className="mt-1">Bei Verlust Ihres zweiten Faktors erlaubt jeder Code eine Anmeldung. Sicher aufbewahren (z.B. Passwort-Manager oder Ausdruck im Tresor).</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{step.codes.map((c) => (
|
||||
<code key={c} className="rounded bg-muted px-2 py-1.5 text-center font-mono text-sm">{c}</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => copy(step.codes.join('\n'), 'Backup-Codes')}>
|
||||
{copied === 'Backup-Codes' ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
Alle kopieren
|
||||
</Button>
|
||||
<Button size="sm" className="ml-auto" onClick={() => { setEnableOpen(false); reset(); refetch() }}>
|
||||
<KeyRound className="size-3.5" /> Fertig
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* --- Disable flow --- */}
|
||||
<Dialog open={disableOpen} onOpenChange={(o) => { setDisableOpen(o); if (!o) setPassword('') }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
2FA deaktivieren
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ihr Konto ist dann nur noch durch das Passwort geschützt. Bitte Passwort zur Bestätigung eingeben.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="disable-pw" className="text-xs">Passwort</Label>
|
||||
<Input
|
||||
id="disable-pw"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setDisableOpen(false)}>Abbrechen</Button>
|
||||
<Button size="sm" disabled={busy || !password} onClick={disable} className="gap-1.5 bg-foreground text-background hover:bg-foreground/90">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />}
|
||||
Deaktivieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Users, Trash2, ShieldCheck, Loader2 } from 'lucide-react'
|
||||
import { DeleteDialog } from './crud/delete-dialog'
|
||||
import { formatRelative } from '@/lib/format'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface UserRow {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
companyId: string | null
|
||||
emailVerifiedAt: string | null
|
||||
twoFactorEnabled: boolean
|
||||
twoFactorMethod: string | null
|
||||
lastLoginAt: string | null
|
||||
createdAt: string
|
||||
company: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
interface UsersResponse {
|
||||
users: UserRow[]
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
sales: 'Vertrieb',
|
||||
pm: 'Projektmanager',
|
||||
developer: 'Entwickler',
|
||||
finance: 'Buchhaltung',
|
||||
readonly: 'Lesend',
|
||||
client: 'Kunde',
|
||||
member: 'Mitarbeiter',
|
||||
reseller: 'Reseller',
|
||||
}
|
||||
|
||||
const ROLE_VALUES = ['owner', 'admin', 'member', 'sales', 'pm', 'developer', 'finance', 'readonly', 'client', 'reseller']
|
||||
|
||||
export function UserManagement() {
|
||||
const { data, loading, refetch } = useFetch<UsersResponse>('/api/aos/users')
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserRow | null>(null)
|
||||
const [changingRole, setChangingRole] = useState<string | null>(null)
|
||||
|
||||
const changeRole = async (userId: string, role: string) => {
|
||||
setChangingRole(userId)
|
||||
const t = toast.loading('Rolle wird geändert…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ role }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Fehlgeschlagen')
|
||||
toast.success(`Rolle geändert: ${ROLE_LABELS[role] ?? role}`, { id: t })
|
||||
refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setChangingRole(null)
|
||||
}
|
||||
}
|
||||
|
||||
const initials = (name: string) =>
|
||||
name.split(' ').map((p) => p[0]).slice(0, 2).join('').toUpperCase()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
Benutzer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Laden…
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const users = data?.users ?? []
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
Benutzer ({users.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{users.length === 0 ? (
|
||||
<p className="p-4 text-xs text-muted-foreground">Keine Benutzer vorhanden.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Benutzer</TableHead>
|
||||
<TableHead>Rolle</TableHead>
|
||||
<TableHead>2FA</TableHead>
|
||||
<TableHead>Letzte Anmeldung</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-[10px]">
|
||||
{initials(u.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{u.name}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{u.email}</p>
|
||||
{u.company && (
|
||||
<p className="truncate text-[10px] text-muted-foreground">→ {u.company.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.role === 'owner' ? (
|
||||
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||
<ShieldCheck className="size-3" /> Owner
|
||||
</Badge>
|
||||
) : changingRole === u.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Select value={u.role} onValueChange={(v) => changeRole(u.id, v)}>
|
||||
<SelectTrigger className="h-7 w-36 text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_VALUES.map((r) => (
|
||||
<SelectItem key={r} value={r} className="text-xs">
|
||||
{ROLE_LABELS[r] ?? r}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.twoFactorEnabled ? (
|
||||
<Badge variant="outline" className="gap-1 text-[10px] text-foreground">
|
||||
<ShieldCheck className="size-3" />
|
||||
{u.twoFactorMethod === 'totp' ? 'App' : 'E-Mail'}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'nie'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-[10px]"
|
||||
onClick={() => setDeleteTarget(u)}
|
||||
title="Benutzer löschen"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
entityLabel="Benutzer"
|
||||
entityName={deleteTarget.name}
|
||||
endpoint={`/api/aos/users/${deleteTarget.id}`}
|
||||
description={`Möchtest du „${deleteTarget.name}" (${deleteTarget.email}) wirklich löschen? Der Benutzer kann sich danach nicht mehr anmelden. Audit-Log bleibt erhalten.`}
|
||||
onDeleted={refetch}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Regular → Executable
File mode changed.
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon, Upload, Loader2, Check, FileText } from 'lucide-react'
|
||||
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon, Upload, Loader2, Check, FileText, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -9,7 +9,9 @@ import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { CreateDealDialog } from '../create-deal-dialog'
|
||||
import { DealFormDialog, type DealFormValue } from '../crud/deal-form-dialog'
|
||||
import { CompanyFormDialog, type CompanyFormValue } from '../crud/company-form-dialog'
|
||||
import { DeleteDialog } from '../crud/delete-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -45,7 +47,12 @@ const STAGE_COLORS: Record<string, string> = {
|
||||
export function CrmView() {
|
||||
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
|
||||
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [dealDialogOpen, setDealDialogOpen] = useState(false)
|
||||
const [editingDeal, setEditingDeal] = useState<DealFormValue | null>(null)
|
||||
const [companyDialogOpen, setCompanyDialogOpen] = useState(false)
|
||||
const [editingCompany, setEditingCompany] = useState<CompanyFormValue | null>(null)
|
||||
const [deleteCompany, setDeleteCompany] = useState<Company & { persons: Person[]; deals: Deal[] } | null>(null)
|
||||
const [deleteDeal, setDeleteDeal] = useState<Deal & { company: Company } | null>(null)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [importStep, setImportStep] = useState<'upload' | 'map' | 'result'>('upload')
|
||||
const [csvText, setCsvText] = useState('')
|
||||
@@ -81,7 +88,11 @@ export function CrmView() {
|
||||
<Upload className="size-4" />
|
||||
CSV Import
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" variant="outline" onClick={() => { setEditingCompany(null); setCompanyDialogOpen(true) }}>
|
||||
<Plus className="size-4" />
|
||||
Firma
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditingDeal(null); setDealDialogOpen(true) }}>
|
||||
<Plus className="size-4" />
|
||||
Deal
|
||||
</Button>
|
||||
@@ -116,11 +127,20 @@ export function CrmView() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pipeline" className="mt-4">
|
||||
<PipelineBoard pipeline={data.pipeline} />
|
||||
<PipelineBoard
|
||||
pipeline={data.pipeline}
|
||||
onEditDeal={(d) => { setEditingDeal(dealToForm(d)); setDealDialogOpen(true) }}
|
||||
onDeleteDeal={(d) => setDeleteDeal(d)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="companies" className="mt-4">
|
||||
<CompaniesTable companies={data.companies} onSelectCompany={setSelectedCompany} />
|
||||
<CompaniesTable
|
||||
companies={data.companies}
|
||||
onSelectCompany={setSelectedCompany}
|
||||
onEdit={(c) => { setEditingCompany(companyToForm(c)); setCompanyDialogOpen(true) }}
|
||||
onDelete={(c) => setDeleteCompany(c)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="persons" className="mt-4">
|
||||
@@ -128,13 +148,43 @@ export function CrmView() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CreateDealDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
<DealFormDialog
|
||||
open={dealDialogOpen}
|
||||
onOpenChange={setDealDialogOpen}
|
||||
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
|
||||
onCreated={refetch}
|
||||
deal={editingDeal}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
<CompanyFormDialog
|
||||
open={companyDialogOpen}
|
||||
onOpenChange={setCompanyDialogOpen}
|
||||
company={editingCompany}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
{deleteCompany && (
|
||||
<DeleteDialog
|
||||
open={!!deleteCompany}
|
||||
onOpenChange={(o) => !o && setDeleteCompany(null)}
|
||||
entityLabel="Firma"
|
||||
entityName={deleteCompany.name}
|
||||
endpoint={`/api/aos/crm/companies/${deleteCompany.id}`}
|
||||
onDeleted={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteDeal && (
|
||||
<DeleteDialog
|
||||
open={!!deleteDeal}
|
||||
onOpenChange={(o) => !o && setDeleteDeal(null)}
|
||||
entityLabel="Deal"
|
||||
entityName={deleteDeal.title}
|
||||
endpoint={`/api/aos/crm/deals/${deleteDeal.id}`}
|
||||
onDeleted={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CSV Import Dialog */}
|
||||
<Dialog open={importOpen} onOpenChange={(o) => { setImportOpen(o); if (!o) { setCsvText(''); setCsvHeaders([]); setMapping({ name: -1 }); setImportResult(null) } }}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
@@ -295,7 +345,15 @@ export function CrmView() {
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
||||
function PipelineBoard({
|
||||
pipeline,
|
||||
onEditDeal,
|
||||
onDeleteDeal,
|
||||
}: {
|
||||
pipeline: CrmData['pipeline']
|
||||
onEditDeal: (d: Deal & { company: Company }) => void
|
||||
onDeleteDeal: (d: Deal & { company: Company }) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3 overflow-x-auto pb-4 [scrollbar-width:thin]">
|
||||
{pipeline.map((col) => {
|
||||
@@ -316,8 +374,28 @@ function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
||||
)}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-2 [scrollbar-width:thin]" style={{ maxHeight: '60vh' }}>
|
||||
{col.deals.map((d) => (
|
||||
<Card key={d.id} className="cursor-pointer p-3 transition hover:border-foreground/30 hover:shadow-sm">
|
||||
<p className="text-xs font-medium leading-snug">{d.title}</p>
|
||||
<Card key={d.id} className="group p-3 transition hover:border-foreground/30 hover:shadow-sm">
|
||||
<div className="flex items-start justify-between gap-1.5">
|
||||
<p className="min-w-0 flex-1 text-xs font-medium leading-snug">{d.title}</p>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onEditDeal(d) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteDeal(d) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.company.name}</p>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold tabular-nums">{formatEuro(d.valueCents)}</span>
|
||||
@@ -348,7 +426,17 @@ function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['companies']; onSelectCompany: (id: string) => void }) {
|
||||
function CompaniesTable({
|
||||
companies,
|
||||
onSelectCompany,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
companies: CrmData['companies']
|
||||
onSelectCompany: (id: string) => void
|
||||
onEdit: (c: Company & { persons: Person[]; deals: Deal[] }) => void
|
||||
onDelete: (c: Company & { persons: Person[]; deals: Deal[] }) => void
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const filtered = companies.filter(
|
||||
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
|
||||
@@ -376,11 +464,12 @@ function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['co
|
||||
<th className="px-4 py-2 font-medium">Kontakte</th>
|
||||
<th className="px-4 py-2 font-medium">Deals</th>
|
||||
<th className="px-4 py-2 font-medium">Tags</th>
|
||||
<th className="w-20 px-4 py-2 font-medium text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((c) => (
|
||||
<tr key={c.id} className="cursor-pointer transition hover:bg-muted/40" onClick={() => onSelectCompany(c.id)}>
|
||||
<tr key={c.id} className="group cursor-pointer transition hover:bg-muted/40" onClick={() => onSelectCompany(c.id)}>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="size-3.5 text-muted-foreground" />
|
||||
@@ -418,6 +507,26 @@ function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['co
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(c) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(c) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -488,3 +597,39 @@ function parseCSVLine(line: string): string[] {
|
||||
fields.push(current)
|
||||
return fields
|
||||
}
|
||||
|
||||
// Convert a Deal (CRM view shape) into the form's value shape.
|
||||
function dealToForm(d: Deal & { company: Company }): DealFormValue {
|
||||
const dateStr = d.expectedClose
|
||||
? (typeof d.expectedClose === 'string' ? d.expectedClose : d.expectedClose.toISOString()).slice(0, 10)
|
||||
: ''
|
||||
return {
|
||||
id: d.id,
|
||||
companyId: d.company.id,
|
||||
title: d.title,
|
||||
valueEuro: (d.valueCents / 100).toString().replace('.', ','),
|
||||
stage: d.stage,
|
||||
probability: String(d.probability),
|
||||
expectedClose: dateStr,
|
||||
lossReason: d.lossReason ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a Company (CRM view shape) into the form's value shape.
|
||||
function companyToForm(c: Company): CompanyFormValue {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
legalForm: c.legalForm ?? '',
|
||||
vatId: c.vatId ?? '',
|
||||
registerCourt: '',
|
||||
registerNumber: '',
|
||||
website: c.website ?? '',
|
||||
email: c.email ?? '',
|
||||
phone: c.phone ?? '',
|
||||
city: c.city ?? '',
|
||||
zip: c.zip ?? '',
|
||||
street: c.street ?? '',
|
||||
tags: c.tags ?? '',
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Loader2,
|
||||
Receipt,
|
||||
TrendingUp,
|
||||
Pencil,
|
||||
Ban,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -17,11 +19,22 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts'
|
||||
import { ExpenseFormDialog, type ExpenseFormValue } from '../crud/expense-form-dialog'
|
||||
|
||||
interface Expense {
|
||||
id: string
|
||||
@@ -33,6 +46,8 @@ interface Expense {
|
||||
taxBp: number
|
||||
grossCents: number
|
||||
date: string
|
||||
companyId: string | null
|
||||
projectId: string | null
|
||||
billable: boolean
|
||||
markupPct: number
|
||||
recurring: boolean
|
||||
@@ -79,6 +94,15 @@ const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
export function ExpensesView() {
|
||||
const { data, loading, refetch } = useFetch<ExpensesData>('/api/aos/expenses')
|
||||
const [converting, setConverting] = useState<string | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<ExpenseFormValue | null>(null)
|
||||
const [stornoTarget, setStornoTarget] = useState<Expense | null>(null)
|
||||
|
||||
// Companies + projects needed for the create/edit dialog (project linking).
|
||||
const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm')
|
||||
const { data: projectsData } = useFetch<{ projects: { id: string; name: string }[] } | null>('/api/aos/projects')
|
||||
const companies = crmData?.companies ?? []
|
||||
const projectOptions = projectsData?.projects ?? []
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
@@ -121,7 +145,7 @@ export function ExpensesView() {
|
||||
title="Ausgaben"
|
||||
description="Agentur-Ausgaben mit Projekt-/Kundenbezug, belastbare Posten (billable), Wiederkehrendes und Umwandlung in Rechnungen. DATEV-Export für den Steuerberater."
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => { setEditTarget(null); setDialogOpen(true) }}>
|
||||
<Plus className="size-4" />
|
||||
Ausgabe
|
||||
</Button>
|
||||
@@ -191,8 +215,10 @@ export function ExpensesView() {
|
||||
{expenses.map((e) => {
|
||||
const st = STATUS_META[e.status] ?? STATUS_META.recorded
|
||||
const canConvert = e.billable && e.status !== 'invoiced' && e.companyName
|
||||
const canEdit = !e.datevExported && e.status !== 'invoiced'
|
||||
const canStorno = !e.datevExported && e.status !== 'invoiced' && e.status !== 'rejected'
|
||||
return (
|
||||
<tr key={e.id} className="transition hover:bg-muted/40">
|
||||
<tr key={e.id} className="group transition hover:bg-muted/40">
|
||||
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[e.categoryId] ?? e.categoryId}</Badge>
|
||||
@@ -214,6 +240,7 @@ export function ExpensesView() {
|
||||
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{canConvert ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -229,9 +256,28 @@ export function ExpensesView() {
|
||||
<Badge variant="outline" className="gap-1 text-[9px]">
|
||||
<Receipt className="size-2.5" />Berechnet
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
) : null}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditTarget(expenseToForm(e)); setDialogOpen(true) }}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canStorno && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStornoTarget(e)}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
||||
title="Storno (GoBD)"
|
||||
>
|
||||
<Ban className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -323,6 +369,140 @@ export function ExpensesView() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ExpenseFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
companies={companies}
|
||||
projects={projectOptions}
|
||||
expense={editTarget}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
<StornoDialog
|
||||
expense={stornoTarget}
|
||||
onOpenChange={(o) => !o && setStornoTarget(null)}
|
||||
onDone={refetch}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StornoDialog — GoBD reversal of an expense (cancel, not delete).
|
||||
// Calls PATCH /api/aos/expenses/[id]/storno with a reason. The expense row is
|
||||
// retained (status='rejected'); an AuditLog entry is the durable record.
|
||||
// ---------------------------------------------------------------------------
|
||||
function StornoDialog({
|
||||
expense,
|
||||
onOpenChange,
|
||||
onDone,
|
||||
}: {
|
||||
expense: Expense | null
|
||||
onOpenChange: (o: boolean) => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const [reason, setReason] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reset = () => setReason('')
|
||||
|
||||
const commit = async () => {
|
||||
if (!expense) return
|
||||
if (reason.trim().length < 3) {
|
||||
toast.error('Bitte einen Storno-Grund angeben (mind. 3 Zeichen).')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const t = toast.loading('Ausgabe wird storniert…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/expenses/${expense.id}/storno`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Storno fehlgeschlagen')
|
||||
toast.success(res.message ?? 'Ausgabe storniert (GoBD)', { id: t, duration: 5000 })
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
onDone()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Storno fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!expense}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) reset()
|
||||
onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Ban className="size-4 text-muted-foreground" />
|
||||
Ausgabe stornieren
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{expense?.number} — {expense?.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="storno-reason" className="text-xs">
|
||||
Storno-Grund <span className="text-muted-foreground">(GoBD — wird im Audit-Log protokolliert)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="storno-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="z. B. Doppelte Buchung, falscher Betrag, Lieferung storniert…"
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Die Ausgabe wird auf „Abgelehnt“ gesetzt. Der Datensatz bleibt gemäß GoBD erhalten und wird
|
||||
nicht gelöscht. DATEV-exportierte Ausgaben sind gesperrt.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || reason.trim().length < 3}
|
||||
onClick={() => void commit()}
|
||||
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Ban className="size-3.5" />}
|
||||
Storno
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Convert an Expense (view shape) into the form's value shape.
|
||||
function expenseToForm(e: Expense): ExpenseFormValue {
|
||||
return {
|
||||
id: e.id,
|
||||
categoryId: e.categoryId,
|
||||
vendor: e.vendor ?? '',
|
||||
description: e.description,
|
||||
amountEuro: (e.amountCents / 100).toString().replace('.', ','),
|
||||
taxBp: String(e.taxBp),
|
||||
date: e.date ? e.date.slice(0, 10) : '',
|
||||
companyId: e.companyId ?? '',
|
||||
projectId: e.projectId ?? '',
|
||||
billable: e.billable,
|
||||
markupPct: String(e.markupPct),
|
||||
recurring: e.recurring,
|
||||
recurringInterval: e.recurringInterval ?? 'monthly',
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -15,6 +15,7 @@ import { LOCALES } from '@/lib/i18n'
|
||||
import { Loader2, Save, Building2, FileText, Percent, Radar, FileCheck2, Languages, Lock, CheckCircle2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { UserManagement } from '../user-management'
|
||||
|
||||
interface SettingsData {
|
||||
settings: Record<string, string>
|
||||
@@ -34,6 +35,11 @@ export function GlobalSettingsView() {
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const update = useCallback((key: string, value: string) => {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
setDirty(true)
|
||||
}, [])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
@@ -43,11 +49,6 @@ export function GlobalSettingsView() {
|
||||
)
|
||||
}
|
||||
|
||||
const update = (key: string, value: string) => {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
const t = toast.loading('Einstellungen werden gespeichert…')
|
||||
@@ -69,20 +70,6 @@ export function GlobalSettingsView() {
|
||||
}
|
||||
}
|
||||
|
||||
const Field = ({ label, k, mono, suffix }: { label: string; k: string; mono?: boolean; suffix?: string }) => (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={form[k] ?? ''}
|
||||
onChange={(e) => update(k, e.target.value)}
|
||||
className={cn('text-sm', mono && 'font-mono')}
|
||||
/>
|
||||
{suffix && <span className="shrink-0 text-xs text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
@@ -125,14 +112,17 @@ export function GlobalSettingsView() {
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<Field label="Standard-Sprache (neue Nutzer)" k="locale.default" />
|
||||
<Field label="Verfügbare Sprachen (komma-getrennt)" k="locale.available" />
|
||||
<SettingsField form={form} update={update} label="Standard-Sprache (neue Nutzer)" k="locale.default" />
|
||||
<SettingsField form={form} update={update} label="Verfügbare Sprachen (komma-getrennt)" k="locale.available" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Zahlen-/Datumsformat folgt der UI-Sprache: DE = 1.234,56 € / 26.10.2026, EN = 1,234.56 € / 10/26/2026, TR = 1.234,56 ₺ / 26.10.2026
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User management */}
|
||||
<UserManagement />
|
||||
|
||||
{/* Company data */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -142,16 +132,16 @@ export function GlobalSettingsView() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Firmenname" k="company.name" />
|
||||
<Field label="Rechtsform" k="company.legalForm" />
|
||||
<Field label="USt-IdNr." k="company.vatId" mono />
|
||||
<Field label="Registergericht" k="company.registerCourt" />
|
||||
<Field label="HRB-Nummer" k="company.registerNumber" mono />
|
||||
<Field label="Stadt" k="company.city" />
|
||||
<Field label="PLZ" k="company.zip" />
|
||||
<Field label="Straße" k="company.street" />
|
||||
<Field label="IBAN" k="company.bank" mono />
|
||||
<Field label="BIC" k="company.bic" mono />
|
||||
<SettingsField form={form} update={update} label="Firmenname" k="company.name" />
|
||||
<SettingsField form={form} update={update} label="Rechtsform" k="company.legalForm" />
|
||||
<SettingsField form={form} update={update} label="USt-IdNr." k="company.vatId" mono />
|
||||
<SettingsField form={form} update={update} label="Registergericht" k="company.registerCourt" />
|
||||
<SettingsField form={form} update={update} label="HRB-Nummer" k="company.registerNumber" mono />
|
||||
<SettingsField form={form} update={update} label="Stadt" k="company.city" />
|
||||
<SettingsField form={form} update={update} label="PLZ" k="company.zip" />
|
||||
<SettingsField form={form} update={update} label="Straße" k="company.street" />
|
||||
<SettingsField form={form} update={update} label="IBAN" k="company.bank" mono />
|
||||
<SettingsField form={form} update={update} label="BIC" k="company.bic" mono />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -164,9 +154,9 @@ export function GlobalSettingsView() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Field label="Präfix" k="invoice.numberPrefix" mono />
|
||||
<Field label="Jahr" k="invoice.numberYear" mono />
|
||||
<Field label="Nächste Nummer" k="invoice.numberNext" mono />
|
||||
<SettingsField form={form} update={update} label="Präfix" k="invoice.numberPrefix" mono />
|
||||
<SettingsField form={form} update={update} label="Jahr" k="invoice.numberYear" mono />
|
||||
<SettingsField form={form} update={update} label="Nächste Nummer" k="invoice.numberNext" mono />
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<p className="rounded-md border border-dashed border-border p-2.5 text-[11px] text-muted-foreground">
|
||||
@@ -185,9 +175,9 @@ export function GlobalSettingsView() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Field label="Standard-Steuersatz" k="tax.standardBp" suffix="Bp (1900 = 19%)" />
|
||||
<Field label="Ermäßigter Steuersatz" k="tax.reducedBp" suffix="Bp (700 = 7%)" />
|
||||
<Field label="Reverse Charge (Default)" k="tax.reverseChargeDefault" />
|
||||
<SettingsField form={form} update={update} label="Standard-Steuersatz" k="tax.standardBp" suffix="Bp (1900 = 19%)" />
|
||||
<SettingsField form={form} update={update} label="Ermäßigter Steuersatz" k="tax.reducedBp" suffix="Bp (700 = 7%)" />
|
||||
<SettingsField form={form} update={update} label="Reverse Charge (Default)" k="tax.reverseChargeDefault" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -200,9 +190,9 @@ export function GlobalSettingsView() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Field label="Such-Radius" k="leadFinder.radiusKm" suffix="km" />
|
||||
<Field label="Kosten-Limit pro Lauf" k="leadFinder.costCapEur" suffix="€" />
|
||||
<Field label="Auto-Genehmigung ab Score" k="leadFinder.autoApproveThreshold" suffix="/ 100" />
|
||||
<SettingsField form={form} update={update} label="Such-Radius" k="leadFinder.radiusKm" suffix="km" />
|
||||
<SettingsField form={form} update={update} label="Kosten-Limit pro Lauf" k="leadFinder.costCapEur" suffix="€" />
|
||||
<SettingsField form={form} update={update} label="Auto-Genehmigung ab Score" k="leadFinder.autoApproveThreshold" suffix="/ 100" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -227,7 +217,7 @@ export function GlobalSettingsView() {
|
||||
<option value="both">Beide (Kunde wählt)</option>
|
||||
</select>
|
||||
</div>
|
||||
<Field label="Leitweg-ID (öffentliche Kunden)" k="erechnung.leitwegId" mono />
|
||||
<SettingsField form={form} update={update} label="Leitweg-ID (öffentliche Kunden)" k="erechnung.leitwegId" mono />
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
@@ -245,9 +235,9 @@ export function GlobalSettingsView() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="Aufbewahrung Rechnungen" k="compliance.retentionInvoicesYears" suffix="Jahre (8)" />
|
||||
<Field label="Aufbewahrung Geschäftsbriefe" k="compliance.retentionBusinessYears" suffix="Jahre (6)" />
|
||||
<Field label="Encryption-Key Escrow" k="compliance.encryptionKeyEscrow" />
|
||||
<SettingsField form={form} update={update} label="Aufbewahrung Rechnungen" k="compliance.retentionInvoicesYears" suffix="Jahre (8)" />
|
||||
<SettingsField form={form} update={update} label="Aufbewahrung Geschäftsbriefe" k="compliance.retentionBusinessYears" suffix="Jahre (6)" />
|
||||
<SettingsField form={form} update={update} label="Encryption-Key Escrow" k="compliance.encryptionKeyEscrow" />
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px]">
|
||||
@@ -268,10 +258,10 @@ export function GlobalSettingsView() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="SMTP-Host" k="mail.smtpHost" />
|
||||
<Field label="SMTP-Port" k="mail.smtpPort" mono />
|
||||
<Field label="IMAP-Host" k="mail.imapHost" />
|
||||
<Field label="IMAP-Port" k="mail.imapPort" mono />
|
||||
<SettingsField form={form} update={update} label="SMTP-Host" k="mail.smtpHost" />
|
||||
<SettingsField form={form} update={update} label="SMTP-Port" k="mail.smtpPort" mono />
|
||||
<SettingsField form={form} update={update} label="IMAP-Host" k="mail.imapHost" />
|
||||
<SettingsField form={form} update={update} label="IMAP-Port" k="mail.imapPort" mono />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -292,3 +282,34 @@ export function GlobalSettingsView() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsField({
|
||||
label,
|
||||
k,
|
||||
mono,
|
||||
suffix,
|
||||
form,
|
||||
update,
|
||||
}: {
|
||||
label: string
|
||||
k: string
|
||||
mono?: boolean
|
||||
suffix?: string
|
||||
form: Record<string, string>
|
||||
update: (key: string, value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`setting-${k}`} className="text-xs">{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`setting-${k}`}
|
||||
value={form[k] ?? ''}
|
||||
onChange={(e) => update(k, e.target.value)}
|
||||
className={cn('text-sm', mono && 'font-mono')}
|
||||
/>
|
||||
{suffix && <span className="shrink-0 text-xs text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Regular → Executable
+227
-90
@@ -12,6 +12,8 @@ import {
|
||||
ExternalLink,
|
||||
Ban,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
RotateCcw,
|
||||
CalendarPlus,
|
||||
Trash2,
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
CircleDashed,
|
||||
Network,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card } from '@/components/ui/card'
|
||||
@@ -154,10 +158,12 @@ interface ProvisioningJob {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface InstanceDetail extends Instance {
|
||||
interface InstanceDetail {
|
||||
instance: Instance & {
|
||||
events: InstanceEvent[]
|
||||
jobs: ProvisioningJob[]
|
||||
}
|
||||
}
|
||||
|
||||
interface InstanceStats {
|
||||
total: number
|
||||
@@ -171,6 +177,17 @@ interface InstanceStats {
|
||||
expiringSoon: number
|
||||
}
|
||||
|
||||
interface AllowedRepoLite {
|
||||
id: string
|
||||
giteaOwner: string
|
||||
giteaRepo: string
|
||||
displayName: string
|
||||
description: string | null
|
||||
product: string
|
||||
isPublic: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface InstancesData {
|
||||
instances: Instance[]
|
||||
stats: InstanceStats
|
||||
@@ -553,7 +570,7 @@ function InstanceDetailSheet({
|
||||
}, [open])
|
||||
|
||||
const handleAction = async (
|
||||
action: 'suspend' | 'reactivate' | 'extend' | 'retry',
|
||||
action: 'suspend' | 'reactivate' | 'extend' | 'retry' | 'redeploy' | 'stop' | 'start',
|
||||
label: string,
|
||||
extendDays?: number
|
||||
) => {
|
||||
@@ -603,18 +620,22 @@ function InstanceDetailSheet({
|
||||
}
|
||||
|
||||
// Parse metadata for Dokploy resource IDs
|
||||
const instance = data?.instance ?? null
|
||||
const status = instance?.status ?? ''
|
||||
const fullDomain = instance ? `${instance.subdomain}.dfmpanel.eu` : ''
|
||||
|
||||
// Safe accessors — events and license may be undefined during loading
|
||||
const events = instance?.events ?? []
|
||||
const license = instance?.license ?? null
|
||||
|
||||
const meta = useMemo(() => {
|
||||
if (!data?.metadata) return {}
|
||||
if (!instance?.metadata) return {}
|
||||
try {
|
||||
return JSON.parse(data.metadata) as Record<string, unknown>
|
||||
return JSON.parse(instance.metadata) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}, [data?.metadata])
|
||||
|
||||
const instance = data
|
||||
const status = instance?.status ?? ''
|
||||
const fullDomain = instance ? `${instance.subdomain}.dfmpanel.eu` : ''
|
||||
}, [instance?.metadata])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -720,7 +741,7 @@ function InstanceDetailSheet({
|
||||
</section>
|
||||
|
||||
{/* License info */}
|
||||
{instance.license && (
|
||||
{license && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Lizenz
|
||||
@@ -730,20 +751,20 @@ function InstanceDetailSheet({
|
||||
label="Key"
|
||||
value={
|
||||
<code className="text-xs">
|
||||
{maskLicenseKey(instance.license.key)}
|
||||
{maskLicenseKey(license.key)}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Produkt" value={instance.license.product} />
|
||||
<DetailRow label="Produkt" value={license.product} />
|
||||
<DetailRow
|
||||
label="Abrechnung"
|
||||
value={
|
||||
instance.license.billingCycle === 'yearly' ? 'Jährlich' : 'Monatlich'
|
||||
license.billingCycle === 'yearly' ? 'Jährlich' : 'Monatlich'
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Nettopreis"
|
||||
value={`${(instance.license.netPriceCents / 100).toLocaleString('de-DE', {
|
||||
value={`${(license.netPriceCents / 100).toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} €`}
|
||||
@@ -752,15 +773,15 @@ function InstanceDetailSheet({
|
||||
label="Lizenzstatus"
|
||||
value={
|
||||
<Badge variant="outline" className="border-foreground/20">
|
||||
{instance.license.status}
|
||||
{license.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Gekündigt"
|
||||
value={
|
||||
instance.license.cancelledAt
|
||||
? formatDate(instance.license.cancelledAt)
|
||||
license.cancelledAt
|
||||
? formatDate(license.cancelledAt)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
@@ -797,16 +818,73 @@ function InstanceDetailSheet({
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Codebase Graph (Graphify) */}
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Codebase-Graph (Graphify)
|
||||
</h3>
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-xs">
|
||||
{(() => {
|
||||
const graphEvents = events.filter(
|
||||
(e) => e.type.startsWith('graphify.')
|
||||
)
|
||||
const buildQueued = graphEvents.some(
|
||||
(e) => e.type === 'graphify.build.queued'
|
||||
)
|
||||
const buildDone = graphEvents.some(
|
||||
(e) => e.type === 'graphify.build.ok'
|
||||
)
|
||||
const buildFailed = graphEvents.some(
|
||||
(e) => e.type === 'graphify.build.error'
|
||||
)
|
||||
if (buildDone) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="size-3.5 text-foreground" />
|
||||
<span>Graph erstellt — Support-AI kann Codebasis analysieren.</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (buildFailed) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-3.5 text-muted-foreground" />
|
||||
<span>Graph-Erstellung fehlgeschlagen. Siehe Ereignisverlauf.</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (buildQueued) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
<span>Graph wird im Hintergrund erstellt…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDashed className="size-3.5 text-muted-foreground" />
|
||||
<span>Kein Graphify-Graph vorhanden.</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<p className="mt-2 text-[10px] text-muted-foreground">
|
||||
Der Codebase-Graph ermöglicht der Support-AI, relevante
|
||||
Code-Stellen zu finden, wenn ein Ticket erstellt wird.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Status timeline (events) */}
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Ereignisverlauf
|
||||
</h3>
|
||||
{instance.events.length === 0 ? (
|
||||
{events.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Keine Ereignisse protokolliert.</p>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{instance.events.map((ev) => (
|
||||
{events.map((ev) => (
|
||||
<EventRow key={ev.id} event={ev} />
|
||||
))}
|
||||
</ol>
|
||||
@@ -821,9 +899,9 @@ function InstanceDetailSheet({
|
||||
{confirmingDelete ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Die Instanz wird unwiderruflich gelöscht. Dokploy-Ressourcen, DNS-Einträge
|
||||
und Volumes werden entfernt (die Datenbank-Zeile bleibt für das Audit-Log
|
||||
erhalten).
|
||||
Die Instanz wird unwiderruflich gelöscht. Dokploy-Ressourcen, DNS-Einträge,
|
||||
Volumes und der Datenbank-Datensatz (inkl. Events & Jobs) werden entfernt.
|
||||
Nur der Audit-Log-Eintrag bleibt bestehen.
|
||||
</p>
|
||||
<SlideCommit
|
||||
label="Instanz wirklich löschen →"
|
||||
@@ -841,15 +919,60 @@ function InstanceDetailSheet({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ActionsRow
|
||||
status={status}
|
||||
busyAction={busyAction}
|
||||
onSuspend={() => handleAction('suspend', 'Suspendieren')}
|
||||
onReactivate={() => handleAction('reactivate', 'Reaktivieren')}
|
||||
onReactivate={() => handleAction('reactivate', 'Starten')}
|
||||
onExtend={() => handleAction('extend', 'Demo verlängern', 7)}
|
||||
onRetry={() => handleAction('retry', 'Provisioning wiederholen')}
|
||||
onRedeploy={() => handleAction('redeploy', 'Redeploy')}
|
||||
onStop={() => handleAction('stop', 'Stop')}
|
||||
onStart={() => handleAction('start', 'Start')}
|
||||
onDelete={() => setConfirmingDelete(true)}
|
||||
/>
|
||||
{['demo_active', 'active'].includes(status) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-1.5"
|
||||
disabled={busyAction !== null}
|
||||
onClick={async () => {
|
||||
setBusyAction('graph-build')
|
||||
const t = toast.loading('Graphify-Graph wird erstellt…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/instances/${instance.id}/graph-build`, { method: 'POST' })
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'build failed')
|
||||
if (res.status === 'queued') {
|
||||
toast.info('Graphify nicht konfiguriert — Build-Kommando generiert.', {
|
||||
id: t,
|
||||
description: 'Worker muss den Build ausführen.',
|
||||
duration: 6000,
|
||||
})
|
||||
} else if (res.status === 'command-ready') {
|
||||
toast.success('Graphify-Service erreichbar. Build-Kommando bereit.', {
|
||||
id: t,
|
||||
description: 'Worker führt den Build aus.',
|
||||
duration: 6000,
|
||||
})
|
||||
} else {
|
||||
toast.success('Graph-Build gestartet', { id: t })
|
||||
}
|
||||
refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Build fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Network className="size-3.5" />
|
||||
Graph neu erstellen
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -942,6 +1065,9 @@ function ActionsRow({
|
||||
onReactivate,
|
||||
onExtend,
|
||||
onRetry,
|
||||
onRedeploy,
|
||||
onStop,
|
||||
onStart,
|
||||
onDelete,
|
||||
}: {
|
||||
status: string
|
||||
@@ -950,6 +1076,9 @@ function ActionsRow({
|
||||
onReactivate: () => void
|
||||
onExtend: () => void
|
||||
onRetry: () => void
|
||||
onRedeploy: () => void
|
||||
onStop: () => void
|
||||
onStart: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const busy = busyAction !== null
|
||||
@@ -979,43 +1108,30 @@ function ActionsRow({
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'demo_active') {
|
||||
if (status === 'demo_active' || status === 'active') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{btn({
|
||||
onClick: onRedeploy,
|
||||
label: 'Redeploy',
|
||||
icon: RefreshCw,
|
||||
loading: busyAction === 'redeploy',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onStop,
|
||||
label: 'Stop',
|
||||
icon: Square,
|
||||
loading: busyAction === 'stop',
|
||||
})}
|
||||
{status === 'demo_active' && btn({
|
||||
onClick: onExtend,
|
||||
label: 'Demo +7d',
|
||||
icon: CalendarPlus,
|
||||
loading: busyAction === 'extend',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onSuspend,
|
||||
label: 'Suspendieren',
|
||||
icon: Ban,
|
||||
loading: busyAction === 'suspend',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onDelete,
|
||||
label: 'Löschen',
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{btn({
|
||||
onClick: onSuspend,
|
||||
label: 'Suspendieren',
|
||||
icon: Ban,
|
||||
loading: busyAction === 'suspend',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onDelete,
|
||||
label: 'Löschen',
|
||||
label: 'Remove',
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
})}
|
||||
@@ -1025,16 +1141,16 @@ function ActionsRow({
|
||||
|
||||
if (status === 'grace') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{btn({
|
||||
onClick: onReactivate,
|
||||
label: 'Reaktivieren',
|
||||
label: 'Start',
|
||||
icon: Play,
|
||||
loading: busyAction === 'reactivate',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onDelete,
|
||||
label: 'Sofort löschen',
|
||||
label: 'Remove',
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
})}
|
||||
@@ -1044,23 +1160,24 @@ function ActionsRow({
|
||||
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{btn({
|
||||
onClick: onRetry,
|
||||
label: 'Provisioning wiederholen',
|
||||
label: 'Retry',
|
||||
icon: RotateCcw,
|
||||
loading: busyAction === 'retry',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onDelete,
|
||||
label: 'Remove',
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// provisioning / deleting / deleted / requested → no actions
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Für diesen Status sind keine Aktionen verfügbar.
|
||||
</p>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1083,10 +1200,8 @@ function CreateInstanceDialog({
|
||||
|
||||
const [companyId, setCompanyId] = useState('')
|
||||
const [licenseId, setLicenseId] = useState('')
|
||||
const [product, setProduct] = useState('restov1')
|
||||
const [allowedRepoId, setAllowedRepoId] = useState('')
|
||||
const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro')
|
||||
const [gitRepo, setGitRepo] = useState('https://github.com/cetinmustafa83/restov1')
|
||||
const [gitBranch, setGitBranch] = useState('main')
|
||||
const [isDemo, setIsDemo] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -1098,6 +1213,11 @@ function CreateInstanceDialog({
|
||||
[crmData?.companies]
|
||||
)
|
||||
|
||||
// Fetch repos available to the selected company
|
||||
const { data: reposData } = useFetch<{ repos: AllowedRepoLite[] }>(
|
||||
companyId ? `/api/aos/repos?companyId=${companyId}` : null
|
||||
)
|
||||
|
||||
// Filter licenses: same company, no existing live instance.
|
||||
const availableLicenses = useMemo(() => {
|
||||
const all = licensesData?.licenses ?? []
|
||||
@@ -1120,22 +1240,28 @@ function CreateInstanceDialog({
|
||||
if (availableLicenses.length === 0) setLicenseId('')
|
||||
}, [availableLicenses, licenseId])
|
||||
|
||||
// Auto-select first available repo
|
||||
useEffect(() => {
|
||||
if (reposData?.repos?.[0] && !reposData.repos.find((r) => r.id === allowedRepoId)) {
|
||||
setAllowedRepoId(reposData.repos[0].id)
|
||||
}
|
||||
if (!reposData?.repos?.length) setAllowedRepoId('')
|
||||
}, [reposData, allowedRepoId])
|
||||
|
||||
// Reset form when closing
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
setCompanyId('')
|
||||
setLicenseId('')
|
||||
setProduct('restov1')
|
||||
setAllowedRepoId('')
|
||||
setPlan('pro')
|
||||
setGitRepo('https://github.com/cetinmustafa83/restov1')
|
||||
setGitBranch('main')
|
||||
setIsDemo(true)
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
if (!companyId) { toast.error('Bitte Firma auswählen'); return }
|
||||
if (!licenseId) { toast.error('Bitte Lizenz auswählen'); return }
|
||||
if (!product.trim()) { toast.error('Produkt eingeben'); return }
|
||||
if (!allowedRepoId) { toast.error('Bitte Produkt/Repo auswählen'); return }
|
||||
setSaving(true)
|
||||
const t = toast.loading('Instanz wird provisioniert (1–3 Min)…')
|
||||
try {
|
||||
@@ -1145,10 +1271,8 @@ function CreateInstanceDialog({
|
||||
body: JSON.stringify({
|
||||
companyId,
|
||||
licenseId,
|
||||
product: product.trim(),
|
||||
allowedRepoId,
|
||||
plan,
|
||||
gitRepo: gitRepo.trim(),
|
||||
gitBranch: gitBranch.trim(),
|
||||
isDemo,
|
||||
}),
|
||||
})
|
||||
@@ -1167,6 +1291,8 @@ function CreateInstanceDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const availableRepos = reposData?.repos ?? []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
@@ -1222,12 +1348,36 @@ function CreateInstanceDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Produkt + Plan */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Produkt/Repo (from AllowedRepo list) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Produkt</Label>
|
||||
<Input value={product} onChange={(e) => setProduct(e.target.value)} />
|
||||
<Label className="text-xs">Produkt / Repository</Label>
|
||||
<Select value={allowedRepoId} onValueChange={setAllowedRepoId} disabled={!companyId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!companyId
|
||||
? 'Zuerst Firma wählen'
|
||||
: availableRepos.length === 0
|
||||
? 'Keine Repos verfügbar'
|
||||
: 'Produkt wählen'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableRepos.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.displayName}
|
||||
{r.description ? ` — ${r.description.slice(0, 50)}` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Repos werden vom Admin freigegeben. Quelle: Gitea (git.dfmpanel.eu), Branch: main.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plan */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Plan</Label>
|
||||
<Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
|
||||
@@ -1239,19 +1389,6 @@ function CreateInstanceDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Git */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Git Repo</Label>
|
||||
<Input value={gitRepo} onChange={(e) => setGitRepo(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Git Branch</Label>
|
||||
<Input value={gitBranch} onChange={(e) => setGitBranch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Switch */}
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
||||
@@ -1269,7 +1406,7 @@ function CreateInstanceDialog({
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving || !licenseId}>
|
||||
<Button onClick={submit} disabled={saving || !licenseId || !allowedRepoId}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
Provisionieren
|
||||
</Button>
|
||||
|
||||
@@ -621,6 +621,11 @@ function LicenseRow({
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
{cancelled && (
|
||||
<Badge variant="outline" className="h-7 gap-1 text-[10px] text-muted-foreground" title="GoBD: storniert — Datensatz bleibt erhalten">
|
||||
<Ban className="size-3" />Storniert
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Timer,
|
||||
Play,
|
||||
Pause,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -31,6 +33,8 @@ import { toast } from 'sonner'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ProjectFormDialog, type ProjectFormValue } from '../crud/project-form-dialog'
|
||||
import { DeleteDialog } from '../crud/delete-dialog'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -81,6 +85,13 @@ const TASK_ICON = {
|
||||
export function ProjectsView() {
|
||||
const { data, loading, refetch } = useFetch<ProjectsData>('/api/aos/projects')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<ProjectFormValue | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
||||
|
||||
// Fetch companies for the create/edit dialog (CRM endpoint already returns them).
|
||||
const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm')
|
||||
const companies = crmData?.companies ?? []
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
@@ -104,7 +115,7 @@ export function ProjectsView() {
|
||||
title="Projekte"
|
||||
description="Projekte, Aufgaben, Zeitverfolgung, Budgets und Change Requests. Mit Client-Portal-Freigaben."
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => { setEditTarget(null); setCreateOpen(true) }}>
|
||||
<Plus className="size-4" />
|
||||
Projekt
|
||||
</Button>
|
||||
@@ -139,7 +150,7 @@ export function ProjectsView() {
|
||||
return (
|
||||
<Card
|
||||
key={p.id}
|
||||
className={cn('cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
|
||||
className={cn('group cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -147,7 +158,27 @@ export function ProjectsView() {
|
||||
<p className="truncate text-sm font-semibold">{p.name}</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{p.company.name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setEditTarget(projectToForm(p)); setCreateOpen(true) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(p) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
@@ -193,6 +224,29 @@ export function ProjectsView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
companies={companies}
|
||||
project={editTarget}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
entityLabel="Projekt"
|
||||
entityName={deleteTarget.name}
|
||||
endpoint={`/api/aos/projects/${deleteTarget.id}`}
|
||||
onDeleted={() => {
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||
setDeleteTarget(null)
|
||||
refetch()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -423,6 +477,21 @@ function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: (
|
||||
)
|
||||
}
|
||||
|
||||
// Convert a Project (view shape) into the form's value shape.
|
||||
function projectToForm(p: Project): ProjectFormValue {
|
||||
return {
|
||||
id: p.id,
|
||||
companyId: p.company.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
budgetType: p.budgetType,
|
||||
budgetHours: p.budgetHours ? String(p.budgetHours) : '',
|
||||
fixedPriceEuro: p.fixedPriceCents ? (p.fixedPriceCents / 100).toString().replace('.', ',') : '',
|
||||
startDate: p.startDate ? p.startDate.slice(0, 10) : '',
|
||||
dueDate: p.dueDate ? p.dueDate.slice(0, 10) : '',
|
||||
}
|
||||
}
|
||||
|
||||
function TaskColumn({ label, tasks, muted, onTaskClick }: { label: string; tasks: Task[]; muted?: boolean; onTaskClick?: (task: Task) => void }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
Executable
+635
@@ -0,0 +1,635 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
GitBranch,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Globe,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
CircleDashed,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { PageHeader, EmptyState } from '../primitives'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirror the AllowedRepo shape returned by /api/aos/repos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AllowedRepo {
|
||||
id: string
|
||||
giteaOwner: string
|
||||
giteaRepo: string
|
||||
displayName: string
|
||||
description: string | null
|
||||
product: string
|
||||
isPublic: boolean
|
||||
isActive: boolean
|
||||
envTemplate: string | null
|
||||
createdAt: string
|
||||
_count: { assignments: number; instances: number }
|
||||
}
|
||||
|
||||
interface ReposData {
|
||||
repos: AllowedRepo[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ReposView() {
|
||||
const { data, loading, refetch } = useFetch<ReposData>('/api/aos/repos')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<AllowedRepo | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AllowedRepo | null>(null)
|
||||
|
||||
const openCreate = () => {
|
||||
setEditTarget(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (repo: AllowedRepo) => {
|
||||
setEditTarget(repo)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { repos } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Repos"
|
||||
description="Verwaltung der freigegebenen Gitea-Repositories für Tenant-Instanzen."
|
||||
actions={
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
Repo hinzufügen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{repos.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={GitBranch}
|
||||
title="Keine Repos freigegeben"
|
||||
description="Füge ein Repo hinzu, damit Kunden Instanzen erstellen können."
|
||||
action={
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
Repo hinzufügen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card className="gap-0 overflow-hidden p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Gitea Repo</TableHead>
|
||||
<TableHead>Produkt</TableHead>
|
||||
<TableHead>Sichtbarkeit</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Instanzen</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{repos.map((repo) => (
|
||||
<RepoRow
|
||||
key={repo.id}
|
||||
repo={repo}
|
||||
onEdit={() => openEdit(repo)}
|
||||
onDelete={() => setDeleteTarget(repo)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<RepoFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(o) => {
|
||||
setDialogOpen(o)
|
||||
if (!o) setEditTarget(null)
|
||||
}}
|
||||
repo={editTarget}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
<DeleteRepoDialog
|
||||
repo={deleteTarget}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteTarget(null)
|
||||
}}
|
||||
onDeleted={refetch}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repo row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RepoRow({
|
||||
repo,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
repo: AllowedRepo
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{repo.displayName}</span>
|
||||
{repo.description && (
|
||||
<span className="line-clamp-1 max-w-xs text-[11px] text-muted-foreground">
|
||||
{repo.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://git.dfmpanel.eu/${repo.giteaOwner}/${repo.giteaRepo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-mono text-xs hover:underline"
|
||||
>
|
||||
{repo.giteaOwner}/{repo.giteaRepo}
|
||||
<ExternalLink className="size-3 text-muted-foreground" />
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-foreground/20">
|
||||
{repo.product}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<VisibilityBadge isPublic={repo.isPublic} assignments={repo._count.assignments} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge isActive={repo.isActive} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums text-sm">{repo._count.instances}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={onEdit}
|
||||
aria-label="Bearbeiten"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={onDelete}
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Badges
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VisibilityBadge({ isPublic, assignments }: { isPublic: boolean; assignments: number }) {
|
||||
if (isPublic) {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-foreground/30">
|
||||
<Globe className="size-3" />
|
||||
Public
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-foreground/20">
|
||||
<Users className="size-3" />
|
||||
Assigned
|
||||
{assignments > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">· {assignments}</span>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ isActive }: { isActive: boolean }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'gap-1',
|
||||
isActive ? 'border-foreground/40' : 'border-foreground/20 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isActive ? <CheckCircle2 className="size-3" /> : <CircleDashed className="size-3" />}
|
||||
{isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create / Edit dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RepoFormValue {
|
||||
giteaOwner: string
|
||||
giteaRepo: string
|
||||
displayName: string
|
||||
description: string
|
||||
product: string
|
||||
isPublic: boolean
|
||||
isActive: boolean
|
||||
envTemplate: string
|
||||
}
|
||||
|
||||
const EMPTY_FORM: RepoFormValue = {
|
||||
giteaOwner: '',
|
||||
giteaRepo: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
product: '',
|
||||
isPublic: false,
|
||||
isActive: true,
|
||||
envTemplate: '',
|
||||
}
|
||||
|
||||
function RepoFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
repo,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
repo: AllowedRepo | null
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const isEdit = !!repo
|
||||
const [form, setForm] = useState<RepoFormValue>(EMPTY_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (repo) {
|
||||
setForm({
|
||||
giteaOwner: repo.giteaOwner,
|
||||
giteaRepo: repo.giteaRepo,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description ?? '',
|
||||
product: repo.product,
|
||||
isPublic: repo.isPublic,
|
||||
isActive: repo.isActive,
|
||||
envTemplate: repo.envTemplate ?? '',
|
||||
})
|
||||
} else {
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
}
|
||||
}, [open, repo])
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.giteaOwner.trim()) { toast.error('Gitea Owner eingeben'); return }
|
||||
if (!form.giteaRepo.trim()) { toast.error('Gitea Repo eingeben'); return }
|
||||
if (!form.displayName.trim()) { toast.error('Display Name eingeben'); return }
|
||||
if (!form.product.trim()) { toast.error('Produkt eingeben'); return }
|
||||
|
||||
// Validate envTemplate JSON if provided.
|
||||
const env = form.envTemplate.trim()
|
||||
if (env) {
|
||||
try {
|
||||
const parsed = JSON.parse(env)
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
throw new Error('not an object')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Env Template ist kein gültiges JSON-Objekt')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const t = toast.loading(isEdit ? 'Repo wird aktualisiert…' : 'Repo wird hinzugefügt…')
|
||||
try {
|
||||
if (isEdit) {
|
||||
const payload: Record<string, unknown> = {
|
||||
displayName: form.displayName.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
isPublic: form.isPublic,
|
||||
isActive: form.isActive,
|
||||
envTemplate: env || undefined,
|
||||
}
|
||||
const r = await fetch(`/api/aos/repos/${repo!.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const res = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(res.error || 'update failed')
|
||||
toast.success('Repo aktualisiert', { id: t })
|
||||
} else {
|
||||
const payload: Record<string, unknown> = {
|
||||
giteaOwner: form.giteaOwner.trim(),
|
||||
giteaRepo: form.giteaRepo.trim(),
|
||||
displayName: form.displayName.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
product: form.product.trim(),
|
||||
isPublic: form.isPublic,
|
||||
envTemplate: env || undefined,
|
||||
}
|
||||
const r = await fetch('/api/aos/repos', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const res = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(res.error || 'create failed')
|
||||
toast.success('Repo hinzugefügt', { id: t })
|
||||
}
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Repo bearbeiten' : 'Repo hinzufügen'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Repo-Konfiguration aktualisieren. Gitea-Owner und -Repo können nachträglich nicht geändert werden.'
|
||||
: 'Erlaubt das Provisioning von Instanzen aus diesem Gitea-Repository.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Gitea Owner *</Label>
|
||||
<Input
|
||||
value={form.giteaOwner}
|
||||
onChange={(e) => setForm((f) => ({ ...f, giteaOwner: e.target.value }))}
|
||||
placeholder="dfmsolutions"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Gitea Repo *</Label>
|
||||
<Input
|
||||
value={form.giteaRepo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, giteaRepo: e.target.value }))}
|
||||
placeholder="restov1"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Display Name *</Label>
|
||||
<Input
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||
placeholder="Pizzeria RESTO (restov1)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Beschreibung</Label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Kurze Beschreibung des Produkts"
|
||||
className="min-h-[60px] text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Produkt *</Label>
|
||||
<Input
|
||||
value={form.product}
|
||||
onChange={(e) => setForm((f) => ({ ...f, product: e.target.value }))}
|
||||
placeholder="restov1, aos-cms, …"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public switch */}
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs">Öffentlich (Public)</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Wenn aktiv, sehen alle Kunden dieses Repo. Andernfalls nur explizit zugewiesene Firmen.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.isPublic}
|
||||
onCheckedChange={(v) => setForm((f) => ({ ...f, isPublic: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active switch — edit only */}
|
||||
{isEdit && (
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs">Aktiv</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Inaktive Repos sind im Provisioning-Menü ausgeblendet, werden aber nicht gelöscht.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.isActive}
|
||||
onCheckedChange={(v) => setForm((f) => ({ ...f, isActive: v }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Env Template (JSON)</Label>
|
||||
<Textarea
|
||||
value={form.envTemplate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, envTemplate: e.target.value }))}
|
||||
placeholder='{"APP_MODE":"demo","RESTO_QR_SECRET":"…"}'
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Env-Vorlage wird beim Provisioning automatisch eingefügt. Überschreibt nicht die
|
||||
AOS-Lizenz-Variablen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
|
||||
{isEdit ? 'Speichern' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DeleteRepoDialog({
|
||||
repo,
|
||||
onOpenChange,
|
||||
onDeleted,
|
||||
}: {
|
||||
repo: AllowedRepo | null
|
||||
onOpenChange: (o: boolean) => void
|
||||
onDeleted: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const open = !!repo
|
||||
|
||||
const confirm = async () => {
|
||||
if (!repo) return
|
||||
setBusy(true)
|
||||
const t = toast.loading('Repo wird entfernt…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/repos/${repo.id}`, { method: 'DELETE' })
|
||||
const res = await r.json().catch(() => ({}))
|
||||
if (!r.ok) {
|
||||
// 409 = active instances block deletion
|
||||
if (r.status === 409 && typeof res.instances === 'number') {
|
||||
throw new Error(
|
||||
`Repo hat ${res.instances} aktive Instanz(en) — Löschen blockiert.`
|
||||
)
|
||||
}
|
||||
throw new Error(res.error || 'Löschen fehlgeschlagen')
|
||||
}
|
||||
toast.success('Repo entfernt', { id: t })
|
||||
onOpenChange(false)
|
||||
onDeleted()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Löschen fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-muted-foreground" />
|
||||
Repo wirklich entfernen?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{repo
|
||||
? `Bestehende Instanzen werden nicht gelöscht. Das Repo „${repo.displayName}" wird aus der Freigabe-Liste entfernt und steht nicht mehr für neue Provisionings zur Verfügung.`
|
||||
: 'Bestehende Instanzen werden nicht gelöscht.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{repo && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="size-3.5 text-muted-foreground" />
|
||||
<span className="font-mono">{repo.giteaOwner}/{repo.giteaRepo}</span>
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
erstellt {formatDate(repo.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-muted-foreground">
|
||||
<span>{repo._count.instances} Instanzen</span>
|
||||
<span>·</span>
|
||||
<span>{repo._count.assignments} Zuweisungen</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
void confirm()
|
||||
}}
|
||||
disabled={busy}
|
||||
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||
Entfernen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
Executable
+1308
File diff suppressed because it is too large.
Load diff
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user