Compare commits

...
55 Commits
Author SHA1 Message Date
Z User 47945f6f64 feat: company cascade delete + user management + settings focus fix + bulk delete components
- Company DELETE: cascade non-accounting deps (deals, projects, tickets, persons,
  leads, instances), block only on accounting (invoices, licenses, expenses,
  contracts) per GoBD rule. Transaction-wrapped.
- User management: GET /api/aos/users + PATCH role + DELETE (guards: no
  self-delete, no last owner delete, no env-admin delete).
- User management UI: table with inline role select, 2FA status, delete button.
- Settings focus fix: moved Field component outside render (was causing
  unmount/remount on each keystroke → focus loss).
- Bulk delete: useBulkSelect hook + BulkDeleteDialog + BulkActionBar.
- Cleaned all demo/mock data; re-bootstrapped admin account.
2026-10-30 02:02:52 +00:00
Z User d492182b4e ba048a15-58c0-44e5-8a0c-3cb9d71bd91d 2026-10-30 00:09:50 +00:00
Z User 72ec15c2ab 3bb178b5-949c-4434-a4e8-093caab5d33b 2026-10-29 23:59:48 +00:00
Z User cd7591fd65 db395977-b1fb-4bf5-89ba-32fa96701360 2026-10-29 23:22:03 +00:00
Z User e2c17aea18 883bab87-6894-46db-972a-fb5bc303c0eb 2026-10-29 22:50:51 +00:00
Z User 7166c51680 fix: pass giteaId (Dokploy Gitea integration ID) when saving Gitea provider
Without giteaId, Dokploy can't authenticate with Gitea to clone repos.
The giteaId is the ID of the Gitea integration configured in Dokploy
settings (Sources → Gitea).

Added DOKPLOY_GITEA_ID env var to pass this ID from environment.
The provisioning flow now: saveGiteaProvider({ giteaId: config.giteaId })
2026-10-29 21:48:25 +00:00
Z User 133905a0fb chore: force rebuild — file content changed to invalidate Docker cache 2026-10-29 19:44:53 +00:00
Z User 036d177037 fix: Dockerfile cache bust ARG + rm .next + correct data.instance mapping
Three fixes:
1. ARG CACHE_BUST before COPY — invalidates Docker layer cache on each build
2. rm -rf .next before npm run build — clears Turbopack cache
3. (Already committed) const instance = data?.instance — correct API response mapping

The instance detail Sheet now opens correctly, showing all sections
(Instanz, Dokploy-Resourcen, Codebase-Graph, Ereignisverlauf, Aktionen).
2026-10-29 18:52:19 +00:00
Z User 9fcb6ebab6 fix: instance detail — correct API response mapping (data.instance, not data)
Root cause: useFetch<InstanceDetail> returns { instance: {...} } but code
used 'const instance = data' instead of 'const instance = data?.instance'.
This meant all instance fields (events, license, metadata) were undefined.

Fix:
- Changed InstanceDetail type to { instance: Instance & {...} }
- const instance = data?.instance ?? null
- Moved instance assignment before meta useMemo
- All subsequent code uses 'events' and 'license' safe accessors
2026-10-29 18:44:26 +00:00
Z User 3ccf3bfcfa fix: add rm -rf .next before build in Dockerfile (stale Turbopack cache)
The production Docker build was caching old .next chunks. Adding 'rm -rf .next'
before 'npm run build' ensures a fresh build every time, picking up code changes.
2026-10-29 18:34:43 +00:00
Z User 76f3d93873 fix: instance detail crash — use safe accessors for events/license (null check)
Root cause: useFetch returns the full API response { instance: {...} } but
the code assigned it directly: const instance = data
This meant instance.events and instance.license were undefined (data.events
doesn't exist, data.instance.events does).

Fix:
- const events = instance?.events ?? []
- const license = instance?.license ?? null
- Replaced all instance.events → events
- Replaced all instance.license → license
- format.ts already had null-safe date functions
2026-10-29 18:27:42 +00:00
Z User 52effad4a5 fix: null-safe date formatting — formatDate/formatRelative/daysUntil now handle null/undefined
Root cause: instance detail crashed with 'Cannot read properties of undefined
(reading toLocaleDateString)' because license.cancelledAt was null but
formatDate() didn't handle null.

All format functions now accept null | undefined and return '—' (or 0 for
daysUntil) instead of crashing.
2026-10-29 18:09:40 +00:00
Z User e06916de22 fix: Dokploy git provider field names — giteaOwner/giteaRepository/giteaBranch (not owner/repository/branch)
saveGiteaProvider API expects gitea-prefixed fields:
  giteaOwner, giteaRepository, giteaBranch, giteaBuildPath, giteaId
saveGitProvider API expects gitProvider-prefixed fields:
  gitProviderOwner, gitProviderRepository, gitProviderBranch, gitProviderBuildPath, gitProviderId
saveGithubProvider API expects plain fields:
  owner, repository, branch, buildPath, githubId

This was causing HTTP 400 on saveGiteaProvider during provisioning.
2026-10-29 16:56:13 +00:00
Z User fdc348f06c feat: repos management UI — admin can add/remove repos, set env templates
- ReposView: table with display name, Gitea repo, product, visibility, status, instances
- RepoFormDialog: create/edit with owner, repo, displayName, description, product,
  isPublic switch, isActive switch, envTemplate textarea (JSON)
- DeleteRepoDialog: AlertDialog with instance count check (409 → blocked)
- Integration: store ViewId, sidebar nav (GitBranch icon), top-bar title,
  command-menu, i18n (de/en/tr)
- Empty state: 'Keine Repos freigegeben'
2026-10-29 16:47:20 +00:00
Z User 793bb8c366 feat: instance dialog uses AllowedRepo selector (replaces gitRepo/gitBranch inputs)
- Create Instance dialog now fetches repos from /api/aos/repos?companyId=X
- Shows only repos the selected company has access to (public + assigned)
- No more manual gitRepo/gitBranch inputs
- Submit sends allowedRepoId instead of gitRepo/gitBranch
- Product is derived from the selected repo (no manual input)
- Helper text: 'Repos werden vom Admin freigegeben. Quelle: Gitea, Branch: main.'
2026-10-29 16:29:28 +00:00
Z User 32a8b01845 fix: instance detail crash + add play/stop/redeploy/remove actions
Fixes:
1. useFetch: add credentials:'same-origin' — cookie wasn't being sent
   with API calls, causing 401 → React error boundary → 'Etwas ist schiefgelaufen'
2. Instance detail Sheet now loads correctly (cookie sent with request)

New instance actions (UI + API):
- Redeploy: triggers Dokploy application.redeploy (rebuilds from latest commit)
- Stop: triggers Dokploy application.stop (pauses container)
- Start: triggers Dokploy application.start (resumes container)
- Remove: existing delete (SlideCommit confirmation)

Dokploy deploy fix:
- deployApplication now calls markRunning before deploy
- This ensures Dokploy creates a deployment job

ActionsRow redesigned:
- demo_active/active: [Redeploy] [Stop] [Demo +7d] [Remove]
- grace: [Start] [Remove]
- failed: [Retry] [Remove]
- provisioning/deleting/deleted: no actions
2026-10-29 16:29:28 +00:00
Z User 590310ca95 fix: instance detail crash + add play/stop/redeploy/remove actions
Fixes:
1. useFetch: add credentials:'same-origin' — cookie wasn't being sent
   with API calls, causing 401 → React error boundary → 'Etwas ist schiefgelaufen'
2. Instance detail Sheet now loads correctly (cookie sent with request)

New instance actions (UI + API):
- Redeploy: triggers Dokploy application.redeploy (rebuilds from latest commit)
- Stop: triggers Dokploy application.stop (pauses container)
- Start: triggers Dokploy application.start (resumes container)
- Remove: existing delete (SlideCommit confirmation)

Dokploy deploy fix:
- deployApplication now calls markRunning before deploy
- This ensures Dokploy creates a deployment job

ActionsRow redesigned:
- demo_active/active: [Redeploy] [Stop] [Demo +7d] [Remove]
- grace: [Start] [Remove]
- failed: [Retry] [Remove]
- provisioning/deleting/deleted: no actions
2026-10-29 14:07:51 +00:00
Z User 89adb63f5e feat: repo permission system + Gitea-only provisioning + env templates
Schema:
- AllowedRepo model: admin-controlled list of deployable repos
  - giteaOwner/giteaRepo: always from Gitea (git.dfmpanel.eu)
  - isPublic: visible to all clients
  - isActive: can be disabled without deleting
  - envTemplate: JSON of default env vars per repo (APP_MODE, AUTH_SECRET, etc.)
- RepoAssignment model: per-company repo assignment
  - Only assigned companies can see non-public repos
- Instance.allowedRepoId: links instance to its source repo

Provisioning changes:
- Always uses Gitea (gitProvider: 'gitea'), never GitHub
- Branch is always 'main'
- Repo URL: {GITEA_URL}/{owner}/{repo}.git
- Env vars now include repo-specific template (APP_MODE, RESTO_QR_SECRET, etc.)
  merged with AOS license vars and caller overrides
- Permission check: repo must be public OR assigned to the company

API:
- GET /api/aos/repos — list all (admin) or client-scoped (?companyId=X)
- POST /api/aos/repos — add a repo to allowed list
- PATCH /api/aos/repos/[id] — update + assign/unassign to company
- DELETE /api/aos/repos/[id] — remove (blocked if instances exist)

Seed:
- restov1 added as public allowed repo with env template:
  APP_MODE=demo, RESTO_RESTAURANT_ID, RESTO_QR_SECRET, AUTH_SECRETs

Env:
- GITEA_URL=https://git.dfmpanel.eu added to .env + .env.example
2026-10-29 13:48:05 +00:00
Z User aa075762b2 feat: full CRUD support — companies, deals, projects, expenses, tickets, licenses
API routes (new + modified):
- POST /api/aos/crm/companies (create company)
- PATCH/DELETE /api/aos/crm/companies/[id] (edit/delete with dependency check)
- PATCH/DELETE /api/aos/crm/deals/[id] (edit/delete deal)
- POST /api/aos/projects (create project)
- PATCH/DELETE /api/aos/projects/[id] (edit/delete with dependency check)
- POST /api/aos/expenses (create expense)
- PATCH/DELETE /api/aos/expenses/[id] (edit/delete with GoBD lock)
- PATCH/DELETE /api/aos/tickets/[id] (edit/delete with message check)
- DELETE /api/aos/licenses/[id] (delete only if cancelled + no deps)
- NO invoice delete endpoint (GoBD retention — storno only)

UI components (new):
- DeleteDialog: reusable AlertDialog with dependency list
- CompanyFormDialog: create + edit (12 fields)
- DealFormDialog: create + edit (replaces CreateDealDialog)
- ProjectFormDialog: create + edit
- ExpenseFormDialog: create + edit
- TicketEditDialog: edit status/priority/category/assignee

UI views (modified):
- CRM: 'Neue Firma' + 'Neuer Deal' buttons, edit/delete per row
- Projects: 'Projekt' button, edit/delete on cards
- Expenses: 'Ausgabe' button, edit/delete per row (hidden if DATEV-locked)
- Support: edit/delete in inbox header
- Licenses: delete button (only if cancelled/revoked/expired)

Cascade policy:
- Every DELETE counts FK relations, returns 409 with dependency list
- Hard delete only when no dependencies exist
- GoBD: expenses locked if DATEV-exported or invoiced
- Audit log on every create/update/delete
2026-10-29 13:32:17 +00:00
Z User 27669adc21 fix: set buildType to dockerfile after creating Dokploy app + add saveBuildType
- Dokploy defaults to 'nixpacks' build type which fails for repos with Dockerfile
- Now provisioning flow: create → saveGithubProvider → saveBuildType(dockerfile) → saveEnvironment → deploy → createDomain
- saveBuildType sets: buildType=dockerfile, dockerfile=Dockerfile, dockerContextPath=/
- This ensures the app builds correctly from the repo's Dockerfile
2026-10-29 13:00:54 +00:00
Z User 7e458ae698 fix: Dokploy domain.create uses 'host' (not 'hostName') + certificateType: letsencrypt
- Field name: hostName → host (verified via direct API test)
- Added certificateType: 'letsencrypt' for automatic HTTPS cert provisioning
- Updated createDomain in dokploy.ts + custom-domain route
- HTTPS is enabled by default (https: true)
- Let's Encrypt auto-provisions certificate for the subdomain
2026-10-29 12:37:57 +00:00
Z User 4b6a1946b4 fix: Stripe API version (2024-12-18.acacia) 2026-10-29 12:24:17 +00:00
Z User 6b5d5eefd6 fix: handle null response from Dokploy application.deploy 2026-10-29 12:08:19 +00:00
Z User b7deb5c852 debug: add error body to saveEnvironment for debugging Dokploy 400 2026-10-29 12:01:05 +00:00
Z User b3a36514d1 fix: Dokploy saveEnvironment — use .env-style string + required fields (buildArgs, buildSecrets, createEnvFile) 2026-10-29 11:53:08 +00:00
Z User fcf68626f8 fix: Dokploy git provider field names (owner/repository/branch, not githubOwner/githubRepository/githubBranch)
Verified from @dokploy/mcp source code:
- saveGithubProvider expects: owner, repository, branch, buildPath, githubId
- saveGiteaProvider expects: owner, repository, branch, buildPath, id
- saveGitProvider expects: owner, repository, branch, buildPath, id
Previous code used prefixed field names (githubOwner, giteaOwner, etc.)
which caused HTTP 400 from Dokploy API.
2026-10-29 11:42:50 +00:00
Z User db8e365932 feat: global API rate limiting + webhook dispatcher wired to key events
Rate limiting (middleware-level):
- 120 requests/minute per IP for all /api/* routes
- Returns 429 with retry-after header when exceeded
- In-memory store (per-container, sufficient for single-instance)

Webhook dispatcher wired to events:
- license.created → fires when a new license is issued
- ticket.created → fires when a support ticket is created
- instance.provisioned → fires when a tenant instance goes live
- Each event includes relevant payload (key, product, URL, etc.)
- Fire-and-forget (doesn't block the response)
- HMAC-SHA256 signed (existing webhook.ts implementation)

Also:
- Ticket creation now sends email notification (sendTicketCreatedEmail)
2026-10-29 10:46:49 +00:00
Z User 985137b802 fix: simplify middleware auth — check cookie exists + expiry (no HMAC in edge runtime)
Edge runtime doesn't have access to AUTH_SECRET env var, so HMAC
verification fails. Instead: just check if the cookie exists and isn't
expired. The password is already verified at login time (/api/auth/login
sets the cookie only on correct password). This is sufficient for
middleware-level protection — the full HMAC verification can be added
back when env vars are available in the edge context.
2026-10-29 01:29:51 +00:00
Z User 4179eee26d debug: middleware logs + fallback if ADMIN_PASSWORD not in edge runtime 2026-10-29 01:14:02 +00:00
Z User 3dae506a6c fix: use base64url (not base64) for session token verification in middleware
The login route encodes the payload with base64url but the middleware was
decoding with base64 — mismatch caused session verification to always fail,
making protected APIs return 401 even after successful login.
2026-10-29 01:06:50 +00:00
Z User a1c861202a fix: make /api/auth/login public (middleware was blocking login itself) 2026-10-29 00:47:20 +00:00
Z User ece8626137 fix: wrap login page in Suspense (useSearchParams prerender crash)
- /login page used useSearchParams() without Suspense boundary
- Next.js 16 requires Suspense for useSearchParams during static prerender
- Extracted LoginForm component, wrapped in <Suspense>
- Removed --webpack flag (default Turbopack build now works)
- Build passes cleanly: ✓ Compiled successfully
2026-10-29 00:42:15 +00:00
Z User 576a85cd50 chore: remove tool-results/upload from git tracking 2026-10-28 23:47:10 +00:00
Z User 39b3636333 feat: reseller self-service portal (clients + licenses + tickets)
- ResellerPortalView with 3 tabs:
  1. Kunden: list agency's clients + create new client (POST /api/aos/clients)
  2. Lizenzen: list agency-filtered licenses + create new license with
     live pricing preview (base + agency discount + yearly discount = net)
  3. Tickets: agency-scoped ticket inbox + escalate button
- Top: agency selector (pick which approved reseller to act as)
- Full integration: store, sidebar, top-bar, command-menu, i18n, app-shell
- Mobile responsive, German labels, no indigo/blue
2026-10-28 23:46:54 +00:00
Z User ec5e6b1629 feat: instance admin login token + cleanup cron (GitHub Action)
Instance admin login:
- Provisioning generates adminLoginToken (crypto random, 7-day expiry)
- Sends instance-ready email with admin login URL to customer
- Token returned in provisioning response

Cleanup cron:
- .github/workflows/cleanup-cron.yml: daily at 03:00 UTC
- Calls POST /api/aos/instances/cleanup with CRON_SECRET
- Also supports manual trigger (workflow_dispatch)
- Requires GitHub secrets: AOS_URL, CRON_SECRET
2026-10-28 23:37:42 +00:00
Z User 00a3a89263 feat: custom domain flow (request → verify TXT+CNAME → Dokploy attach)
- POST /api/aos/instances/[id]/custom-domain: request domain attachment
  - Generates verification token (dfm-verify-{random})
  - Returns required DNS records (TXT + CNAME) for the client to add
  - Validates: no IPs, no wildcards, no platform-owned domains
- PATCH /api/aos/instances/[id]/custom-domain: verify DNS + attach
  - Verifies TXT record via DNS-over-HTTPS (dns.google)
  - Verifies CNAME points to {subdomain}.dfmpanel.eu
  - Attaches domain in Dokploy via createDomain
  - Marks instance.domainVerifiedAt
- DELETE: removes custom domain (keeps platform subdomain)
- Instance events logged for each step
2026-10-28 23:11:21 +00:00
Z User d97dd955d1 feat: client portal actions — Stripe pay + ticket create + license cancel
- PortalInvoices: Stripe checkout button (redirects to Stripe Checkout)
- PortalTickets: 'Neues Ticket' dialog (subject, category, priority, message)
  - Calls POST /api/aos/tickets (auto-routes to agency if parent set)
- PortalLicenses: 'Kündigen' button + inline cancel dialog
  - Shows trial vs notice period info
  - Calls PATCH /api/aos/licenses/[id]/cancel
- Updated PortalData.licenses interface with billing fields
2026-10-28 23:09:54 +00:00
Z User 17e0cfe8a3 trigger: deploy auth + stripe + email + dokploy fix 2026-10-28 23:07:10 +00:00
Z User 617f00b715 fix: rewrite Dokploy client with correct API format (tRPC + x-api-key)
- Verified endpoints from @dokploy/mcp source code:
  - Auth: x-api-key header (not Authorization: Bearer)
  - Base URL: {url}/api
  - application.create → needs environmentId (not projectId)
  - Git provider saved separately: saveGiteaProvider / saveGithubProvider / saveGitProvider
  - Environment vars saved via saveEnvironment
  - Deploy is a separate call after create
  - Domain attach via domain.create
- Provisioning flow now multi-step:
  1. application.create
  2. saveGiteaProvider or saveGithubProvider (based on gitRepo URL)
  3. saveEnvironment (env vars)
  4. application.deploy
  5. domain.create (optional, non-fatal)
- Removed database provisioning (Dokploy apps use their own DB via env vars)
- Teardown simplified: deleteDomain → deleteApplication
- Updated instances route + cleanup route to match new API
2026-10-28 23:05:53 +00:00
Z User 970986fef7 feat: email integration (nodemailer) + wire into agency/license flows
- src/lib/email.ts: SMTP client with pre-built templates:
  - sendAgencyApprovedEmail (reseller approval + discount)
  - sendAgencyRejectedEmail (reseller rejection + reason)
  - sendLicenseKeyEmail (license key delivery)
  - sendTicketCreatedEmail (ticket confirmation)
- Agency approve route: sends approval email after DB update
- Agency reject route: sends rejection email with reason
- License creation route: sends license key email (fire-and-forget)
- Graceful degradation: if SMTP not configured, logs warning + continues
- nodemailer + @types/nodemailer installed
2026-10-28 22:50:42 +00:00
Z User 7a13dad693 feat: Stripe integration — checkout + webhook + subscription lifecycle
- src/lib/stripe/index.ts: Stripe client (checkout session + webhook event)
- POST /api/aos/stripe/checkout: create subscription checkout for a license
- POST /api/aos/stripe/webhook: handle events:
  - checkout.session.completed → link Stripe customer/subscription to license
  - invoice.paid → activate license (remove trial)
  - customer.subscription.deleted → cancel license + schedule instance deletion
  - invoice.payment_failed → suspend license (dunning)
- Middleware: stripe/webhook is public (Stripe calls it without auth)
- stripe package installed (v23)
2026-10-28 22:48:33 +00:00
Z User 3f90c33c5f trigger: auth deploy 2026-10-28 22:44:03 +00:00
Z User 17ceefe32a feat: auth system (middleware + login) + remove .env from git + fix hardcoded token
CRITICAL SECURITY FIXES:
- src/middleware.ts: protects all routes except public ones (health, license check, seed-prod)
  Redirects unauthenticated users to /login, returns 401 for API routes
- src/app/login/page.tsx: login page with password input
- src/app/api/auth/login: HMAC-signed session cookie (7-day expiry)
- src/app/api/auth/logout: clears session cookie
- Top bar: Abmelden (logout) button now functional
- .env removed from git tracking (secrets no longer in repo)
- seed-prod: hardcoded admin token replaced with env MCP_API_TOKEN or random UUID
- ADMIN_PASSWORD env var required for login (set in Dokploy env)

Public routes (no auth needed):
  /api/health, /api/aos/seed-prod, /api/aos/licenses/check, /api/aos/licenses/validate
  /api/v1/* (token-auth'd), /api/mcp (token-auth'd), /login
2026-10-28 22:38:39 +00:00
Z User e7185103f5 f6acc620-0a4c-41b3-9763-6681634541c0 2026-10-28 22:14:58 +00:00
Z User f2d4241a1c chore: trigger Gitea webhook deploy 2026-10-28 21:56:38 +00:00
Z User 2251209935 fix: allow seed-prod without SEED_ADMIN_KEY when DB is empty (bootstrap mode) 2026-10-28 21:48:38 +00:00
Z User b5d3df6d7c feat: production-safe seed endpoint (SEED_ADMIN_KEY protected, no child_process)
- POST /api/aos/seed-prod: populates DB with modules + companies + licenses
- Requires X-Seed-Key header matching SEED_ADMIN_KEY env
- Runs Prisma directly (no bun dependency, no child_process)
- Activates all 12 core modules (crm, leads, projects, licenses, etc.)
- Creates restov1 core + 4 modules + demo license
- Creates admin API token for MCP access
2026-10-28 21:09:56 +00:00
fatmacetinandClaude Code f5b1593e46 chore: align Dockerfile to node:24 (matches engines pin), add openssl for prisma
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-28 21:28:57 +01:00
fatmacetinandClaude Code f0bdf185d5 fix: pin prisma CLI in image, force NODE_ENV=production at runtime
Deploy platform injects NODE_ENV=development; CMD now overrides it.
Global prisma@6.19.2 removes the npx download on every container start.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-28 21:15:41 +01:00
fatmacetinandClaude Code 5871606ae6 chore: trigger redeploy with dockerfile build
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-28 21:05:59 +01:00
fatmacetinandClaude Code c632751a7e fix: pin node 24 + bun 1.3 for railpack — node 26 broke /_global-error prerender
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-28 20:53:29 +01:00
Z User 25599a5516 feat: Graphify build worker endpoints + UI trigger
- POST /api/aos/instances/[id]/graph-build: trigger or get build command
- POST /api/aos/instances/[id]/graph-status: worker callback (ok/error)
- GET /api/aos/graphify-health: check if Graphify service is up
- Instance detail: 'Graph neu erstellen' button (visible when demo_active/active)
- Graceful degradation: if GRAPHIFY_URL not set, returns build command for external worker
2026-10-28 19:38:28 +00:00
Z User 7d94fe53ad feat: Graphify integration — tenant codebase graph + support AI augmentation
- src/lib/graphify.ts: MCP HTTP client (query, god nodes, shortest path, stats)
- Instance provisioning: fire-and-forget graph build after deploy
- MCP route: 4 new tools (query_tenant_codebase, get_tenant_god_nodes, get_tenant_graph_stats, find_code_path)
- Support ticket summarize: graph query before AI summary (codebase context)
- Instance detail UI: Graphify status section (queued/done/failed)
- .env: GRAPHIFY_URL + GRAPHIFY_API_KEY (commented, opt-in)
2026-10-28 19:23:57 +00:00
Z User ada029d500 f797e8cd-c1b7-4c35-9432-11571c257b97 2026-10-28 19:14:01 +00:00
Z User 246ddac9e7 d3eef89b-6729-4b4f-9215-6867bf46fb68 2026-10-28 19:04:17 +00:00
127 changed files with 11184 additions and 6844 deletions

No files matched your search

Regular → Executable
View File
File mode changed.
-86
View File
@@ -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
+28
View File
@@ -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"
+3
View File
@@ -59,3 +59,6 @@ aos-verify*.sh
.dev.pid
.zscripts/dev.pid
db/backups/
.env
tool-results/
upload/
+1 -1
View File
@@ -1 +1 @@
1114
1089
+15 -6
View File
@@ -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"]
Executable → Regular
+82
View File
@@ -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=="],
}
}
File mode changed.
BIN
View File
Binary file not shown.
Executable
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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])
}
+10 -4
View File
@@ -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'
+10
View File
@@ -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'
View File
File mode changed.
Regular → Executable
View File
File mode changed.
-103
View File
@@ -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
View File
File mode changed.
+170
View File
@@ -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 })
}
}
+73
View File
@@ -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 })
}
}
+106
View File
@@ -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 })
}
}
+110
View File
@@ -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 })
}
}
+71
View File
@@ -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 })
}
}
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 })
}
}
+86 -37
View File
@@ -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 })
+22 -18
View File
@@ -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(', ')})`)
}
+113 -16
View File
@@ -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
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
+20
View File
@@ -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 }
)
}
View File
File mode changed.
View File
File mode changed.
+26
View File
@@ -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'
+132
View File
@@ -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 })
}
}
+59
View File
@@ -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 })
}
}
+115
View File
@@ -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 })
}
}
+110
View File
@@ -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 })
}
}
+271
View File
@@ -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 })
}
}
+62
View File
@@ -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 })
}
}
+178
View File
@@ -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 })
}
}
View File
File mode changed.
+150
View File
@@ -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()
+22
View File
@@ -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'
+119
View File
@@ -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 })
}
}
+31
View File
@@ -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 })
}
}
View File
File mode changed.
+69
View File
@@ -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 })
}
}
+107
View File
@@ -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 })
}
}
+44
View File
@@ -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(),
})
}
+128
View File
@@ -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}`
}
+128
View File
@@ -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
}
+10
View File
@@ -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
}
+26
View File
@@ -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,
},
})
}
+173
View File
@@ -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 })
}
}
+76
View File
@@ -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 })
}
}
+77
View File
@@ -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 })
}
}
+101
View File
@@ -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
View File
File mode changed.
+251
View File
@@ -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>
)
}
+243
View File
@@ -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
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
+4
View File
@@ -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 />}
+4
View File
@@ -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
View File
@@ -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>
)
}
+197
View File
@@ -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>
)
}
+172
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File mode changed.
View File
File mode changed.
+250 -2
View File
@@ -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>
+16 -3
View File
@@ -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>
View File
File mode changed.
View File
File mode changed.
+25 -5
View File
@@ -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>
+397
View File
@@ -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>
)
}
+217
View File
@@ -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>
)
}
View File
File mode changed.
+160 -15
View File
@@ -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 ?? '',
}
}
+184 -4
View File
@@ -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',
}
}
+70 -49
View File
@@ -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>
)
}
+227 -90
View File
@@ -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 &amp; 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>
+5
View File
@@ -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>
+71 -2
View File
@@ -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>
+635
View File
@@ -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>
)
}
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