Compare commits

25 Commits

Author SHA1 Message Date
DanielS
13f647409f fix(sync): query customers by vowels to avoid crash
All checks were successful
Staging Build / build (push) Successful in 2m56s
2026-07-15 23:50:13 +02:00
DanielS
5d3eed874c feat(sync): add customer synchronization from lic server
All checks were successful
Staging Build / build (push) Successful in 3m0s
2026-07-15 22:48:10 +02:00
DanielS
3a090b7664 docs: document licserver management api
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-07-15 16:18:30 +02:00
DanielS
e2d92a031f feat(licserver): add partner customers proxy API 2026-07-15 16:14:31 +02:00
DanielS
6b9023c871 feat(wizard): connect license lookup panel to real lic server api endpoint
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-15 01:01:01 +02:00
DanielS
f783a98392 docs: add lic server api spec and index in second brain 2026-07-15 00:58:13 +02:00
DanielS
d5593900fb feat(cron): add automated sync endpoint and vercel cron configuration for partners
All checks were successful
Staging Build / build (push) Successful in 2m57s
2026-07-15 00:30:13 +02:00
DanielS
92eb7454dc docs: update database schema documentation in second brain and grand functions 2026-07-15 00:27:03 +02:00
DanielS
cb4787e595 style(admin): replace browser alert and confirm with dialog modals
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-07-15 00:21:38 +02:00
DanielS
3fb80e6404 feat(admin): add manual partner import from lic server
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-15 00:07:31 +02:00
DanielS
a318a5e225 fix(admin): wrap access check in try-catch to prevent loader hanging
All checks were successful
Staging Build / build (push) Successful in 2m57s
2026-07-14 23:52:04 +02:00
DanielS
1b49348577 style: simplify home page layout and fix navbar link 2026-07-14 23:44:21 +02:00
DanielS
fb61f5f396 style: remove text gradients and centered badges 2026-07-14 23:42:28 +02:00
DanielS
7119383139 fix(security): harden licserver config access controls
All checks were successful
Staging Build / build (push) Successful in 2m50s
- Add auth guard to getLicServerConfig() - unauthenticated callers
  now receive null config instead of DB values
- Remove non-existent 'superadmin' role from saveLicServerConfig()
  check (only 'admin' exists per project schema)
- New migration: restrict settings table WRITE to admins only
  (previously any authenticated user could overwrite licserver_api_key)
2026-07-14 17:35:12 +02:00
DanielS
fa93e060c2 feat(admin): LicServer config manageable via admin settings UI
Some checks failed
Staging Build / build (push) Has been cancelled
- Migration: add licserver_base_url/api_key cols to settings table
- New: lib/actions/licserver-config.ts (read/write/test server actions)
- Updated: proxy routes read config from DB with env-var fallback
- Updated: admin/einstellungen adds LicServer card with URL + API key
  input, show/hide toggle, Save and Connection Test buttons
2026-07-14 17:30:37 +02:00
DanielS
be4e9204a3 feat(licserver): add partner API proxy routes and types
All checks were successful
Staging Build / build (push) Successful in 2m54s
- New: lib/types/licserver.ts (PartnerDto, paged result, problem)
- New: app/api/licserver/partners/route.ts (GET search/list)
- New: app/api/licserver/partners/[id]/route.ts (GET by UUID)
- Updated: .env.example with LICSERVER_BASE_URL + LICSERVER_API_KEY

Auth-guarded via Supabase session. API key is server-side only.
Upstream: http://192.168.178.174:9980 (internal container network)
2026-07-14 17:24:00 +02:00
DanielS
2e30974aed fix(wizard): give summary sidebar equal 2/6 col width
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-07-14 16:55:45 +02:00
DanielS
c30505de05 feat(wizard): expand step 3 container to max-w-screen-2xl
Some checks failed
Staging Build / build (push) Failing after 16m18s
2026-07-14 15:24:29 +02:00
DanielS
5a2afd8600 feat(wizard): widen license lookup panel to 2 cols
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-07-14 14:35:00 +02:00
DanielS
72b906779c feat(wizard): add license lookup panel (UI mockup)
All checks were successful
Staging Build / build (push) Successful in 2m55s
Add collapsible LicenseLookupPanel component to step 3
of the order wizard (left column). Panel queries mock
LicServer data by license key and displays product,
customer, dates, status badge and licensed modules.
Real API endpoint can replace mock in lookupLicense().

- New: components/wizard/license-lookup-panel.tsx
- Modified: components/order-wizard.tsx (4-col grid)
2026-07-14 12:57:46 +02:00
DanielS
0b4755d438 feat: split step-3 into independent left/right grid columns with sticky local stepper
All checks were successful
Staging Build / build (push) Successful in 2m51s
2026-07-10 16:10:25 +02:00
DanielS
d37a6b4348 feat: sticky header with stepper for step 3, sidebar aligns under header
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-07-10 15:58:14 +02:00
DanielS
21550f1389 fix: sidebar top-[24vh] prevents stepper overlap, reduce max-h to 70vh
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-07-10 15:43:24 +02:00
DanielS
43e6a92663 feat: implement category flags, drop global tables, fix sidebar sticky layout
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-10 15:27:55 +02:00
DanielS
305671acc6 fix: stepper circles always blue, replace bg-primary with bg-blue-600 2026-07-10 15:23:43 +02:00
33 changed files with 1961 additions and 359 deletions

View File

@@ -83,3 +83,15 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
- `pdf_url` (TEXT)
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
- `created_at` (TIMESTAMPTZ)
### `settings` (Systemeinstellungen)
- Globale Shopeinstellungen (SMTP und LicServer).
- `id` (TEXT, Primary Key, z.B. `'licserver'`, `'smtp'`)
- `host` (TEXT)
- `port` (INTEGER)
- `secure` (BOOLEAN)
- `user` (TEXT)
- `pass` (TEXT)
- `licserver_base_url` (TEXT)
- `licserver_api_key` (TEXT)
- `updated_at` (TIMESTAMPTZ)

View File

@@ -33,4 +33,9 @@ python3 .agents/skills/uiux-designer/scripts/search.py "<keywords>" --design-sys
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --domain <domain>
### Step 4: Stack Guidelines
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --stack html-tailwind
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --stack html-tailwind
## Anti-AI-Design Guidelines
- **NO Centered Badges/Pills**: Do NOT place centered badges or pills (e.g. "CASPOS Lizenz-Portal" or "Ablauf") above main headings. They look like generic, low-quality AI-generated templates.
- **NO Text Gradients**: Avoid text gradients (e.g. `bg-gradient-to-r ... bg-clip-text text-transparent`) on headings. Use flat colors (like `text-white` or solid brand colors).

View File

@@ -2,3 +2,4 @@
- [Supabase Schema](file:///c:/source/webshop/.brain/supabase-schema.md)
- [ADR Log](file:///c:/source/webshop/.brain/adr-log.md)
- [LicServer API Spezifikation](file:///c:/source/webshop/.brain/licserver-api.md)

227
.brain/licserver-api.md Normal file
View File

@@ -0,0 +1,227 @@
# LicServer API Spezifikation (v1)
Dokumentation der REST-Schnittstellen des CASPOS Lizenzservers.
## 1. Endpunkte (Port 9981 - Activation API)
### Activation (Aktivierung)
#### `POST /v1/activate`
Aktiviert eine neue Lizenz.
**Request Body (`application/json`):**
```json
{
"productId": "string",
"productVersion": "string",
"activationCode": "string",
"hardwareBindingType": "string",
"hardwareBindingId": "string"
}
```
**Response `200 OK` (`application/json`):**
```json
{
"licenseData": {
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
},
"apiKey": "string",
"secret": "string"
}
```
**Response Headers:**
- `ETag` (string): Der ETag-Wert der Lizenz.
---
#### `POST /v1/activate-existing`
Aktiviert eine bereits vorhandene Lizenz erneut.
**Request Body (`application/json`):**
```json
{
"productId": "string",
"productVersion": "string",
"serialNumber": "string",
"proofOfPossessionHash": "string",
"salt": "string",
"hardwareBindingType": "string",
"hardwareBindingId": "string"
}
```
**Response `200 OK` (`application/json`):**
*Gleiche Struktur wie bei `/v1/activate`.*
---
### Licenses (Lizenzen)
#### `GET /v1/licenses/{id}`
Holt die Lizenz-Details zu einer bestimmten Lizenz-ID.
**Request Parameters:**
- `id` (path, string, required): Die UUID der Lizenz.
- `If-None-Match` (header, string, optional): ETag zur Cache-Validierung.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
}
```
**Response `304 Not Modified`:**
Falls die Lizenz nicht verändert wurde.
**Response `404 Not Found` / `403 Forbidden`:**
Standard Fehlerobjekt `ProblemDetails`.
---
## 2. Endpunkte (Port 9980 - Management API)
Alle Anfragen an die Management-API müssen authentifiziert sein.
- **Header-Format:** `X-Api-Key: <Schlüssel>`
### Partners (Partner)
#### `GET /api-v1/partners`
Gibt eine paginierte Liste aller Partner zurück.
**Request Parameters (Query):**
- `search` (string, optional): Filtert nach Name/E-Mail.
- `page` (integer, default: 1)
- `pageSize` (integer, default: 20)
**Response `200 OK` (`application/json`):**
```json
{
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"items": [
{
"id": "string (UUID)",
"name": "string",
"email": "string",
"erpId": "string"
}
]
}
```
#### `GET /api-v1/partners/{id}`
Holt Partner-Details zu einer bestimmten ID.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string (UUID)",
"name": "string",
"email": "string",
"erpId": "string"
}
```
### Customers (Kunden von Partnern)
#### `GET /api-v1/partners/{partnerId}/customers`
Gibt eine paginierte Liste aller Kunden eines bestimmten Partners zurück.
**Request Parameters (Query):**
- `search` (string, optional): Filtert nach Name/E-Mail.
- `page` (integer, default: 1)
- `pageSize` (integer, default: 20)
**Response `200 OK` (`application/json`):**
```json
{
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"items": [
{
"id": "string (UUID)",
"name": "string",
"street": "string",
"houseNumber": "string",
"houseNumberAddon": "string",
"zipCode": "string",
"city": "string",
"countryCode": "string",
"email": "string",
"partnerId": "string (UUID)",
"erpId": "string"
}
]
}
```
#### `GET /api-v1/partners/{partnerId}/customers/{customerId}`
Holt detaillierte Kundeninformationen.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string (UUID)",
"name": "string",
"street": "string",
"houseNumber": "string",
"houseNumberAddon": "string",
"zipCode": "string",
"city": "string",
"countryCode": "string",
"email": "string",
"partnerId": "string (UUID)",
"erpId": "string",
"partnerName": "string",
"taxNumber": "string",
"vatIdNumber": "string",
"notes": "string",
"locations": []
}
```
---
## 3. Typen & Schemas
### `LicenseData`
```json
{
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
}
```
### `ProblemDetails`
```json
{
"type": "string",
"title": "string",
"status": 0,
"detail": "string",
"instance": "string"
}
```

View File

@@ -46,3 +46,19 @@
- `order_data` (JSONB)
- `pdf_url` (TEXT)
- `status` (TEXT)
## `categories` (Kategorien)
- Steuert das Layout und Verhalten im Wizard.
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `is_required` (BOOLEAN)
- `allow_multiselect` (BOOLEAN)
- `sort_order` (INTEGER)
- `show_in_branches` (BOOLEAN)
## `settings` (Systemeinstellungen)
- Globale Shopeinstellungen (SMTP & Lizenzserver).
- `id` (TEXT, Primary Key, z.B. `'licserver'`, `'smtp'`)
- `host`, `port`, `secure`, `user`, `pass` (SMTP-Konfiguration)
- `licserver_base_url`, `licserver_api_key` (CASPOS Lizenzserver-Einstellungen)
- `updated_at` (TIMESTAMPTZ)

View File

@@ -2,3 +2,7 @@
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
# CASPOS LicServer (internal container network — server-side only, never expose to browser)
LICSERVER_BASE_URL=http://192.168.178.174:9980
LICSERVER_API_KEY=your-licserver-api-key

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Building2, Plus, Trash2, Loader2, Pencil, Mail, MapPin, Search, Users } from 'lucide-react'
import { Building2, Plus, Trash2, Loader2, Pencil, Mail, MapPin, Search, Users, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -14,7 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getCompanies, createCompany, updateCompany, deleteCompany } from '@/lib/actions/companies'
import { getCompanies, createCompany, updateCompany, deleteCompany, syncCompaniesFromLicServer, syncCustomersFromLicServer } from '@/lib/actions/companies'
export default function CompaniesPage() {
const [companies, setCompanies] = useState<any[]>([])
@@ -32,6 +32,28 @@ export default function CompaniesPage() {
const [createError, setCreateError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [syncing, setSyncing] = useState(false)
const [statusMsg, setStatusMsg] = useState<string | null>(null)
const [statusType, setStatusType] = useState<'success' | 'error' | ''>('')
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [deleteConfirmName, setDeleteConfirmName] = useState('')
const handleSync = async () => {
setSyncing(true)
setStatusMsg(null)
try {
const resCompanies = await syncCompaniesFromLicServer()
const resCustomers = await syncCustomersFromLicServer()
setStatusMsg(`${resCompanies.count} Partner und ${resCustomers.count} Kunden erfolgreich vom LicServer importiert.`)
setStatusType('success')
loadCompanies()
} catch (err: any) {
setStatusMsg(`Fehler beim Import: ${err.message || err}`)
setStatusType('error')
} finally {
setSyncing(false)
}
}
@@ -90,18 +112,7 @@ export default function CompaniesPage() {
const handleDelete = async (id: string, firmName: string) => {
if (!confirm(`Firma "${firmName}" wirklich löschen? Alle zugewiesenen Benutzer werden getrennt.`)) return
setDeletingId(id)
try {
await deleteCompany(id)
loadCompanies()
} catch (err) {
console.error('Fehler beim Löschen:', err)
} finally {
setDeletingId(null)
}
}
const filteredCompanies = companies.filter((c) => {
const nameMatch = c.name?.toLowerCase().includes(searchQuery.toLowerCase())
@@ -123,14 +134,34 @@ export default function CompaniesPage() {
Verwalten Sie Firmen, deren Lieferadresse und weisen Sie Partner zu.
</p>
</div>
<Button
onClick={() => handleOpenDialog()}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
>
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
</Button>
<div className="flex items-center gap-3">
<Button
onClick={handleSync}
disabled={syncing}
variant="outline"
className="border-slate-200 dark:border-white/10 text-slate-900 dark:text-white"
>
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Partner importieren
</Button>
<Button
onClick={() => handleOpenDialog()}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
>
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
</Button>
</div>
</div>
{statusMsg && (
<div className={`p-4 rounded-lg text-sm border ${
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{statusMsg}
</div>
)}
{/* Firmen-Tabelle */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
<div className="relative mb-6 max-w-sm">
@@ -214,11 +245,14 @@ export default function CompaniesPage() {
>
<Pencil className="h-4 w-4" />
</Button>
<Button
<Button
variant="ghost"
size="icon"
disabled={deletingId === company.id}
onClick={() => handleDelete(company.id, company.name)}
onClick={() => {
setDeleteConfirmId(company.id)
setDeleteConfirmName(company.name)
}}
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
title="Firma löschen"
>
@@ -329,6 +363,44 @@ export default function CompaniesPage() {
</DialogContent>
</Dialog>
{/* Dialog: Delete Confirmation */}
<Dialog open={deleteConfirmId !== null} onOpenChange={(open) => { if (!open) setDeleteConfirmId(null) }}>
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
<DialogHeader>
<DialogTitle>Firma löschen</DialogTitle>
<DialogDescription className="text-slate-500 dark:text-slate-400">
Möchten Sie die Firma <strong>{deleteConfirmName}</strong> wirklich löschen? Alle zugewiesenen Benutzer werden getrennt. Dieser Schritt kann nicht rückgängig gemacht werden.
</DialogDescription>
</DialogHeader>
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={() => setDeleteConfirmId(null)}>
Abbrechen
</Button>
<Button
type="button"
onClick={async () => {
if (deleteConfirmId) {
setDeletingId(deleteConfirmId)
const targetId = deleteConfirmId
setDeleteConfirmId(null)
try {
await deleteCompany(targetId)
loadCompanies()
} catch (err) {
console.error('Fehler beim Löschen:', err)
} finally {
setDeletingId(null)
}
}
}}
className="bg-red-600 hover:bg-red-700 text-white font-semibold"
>
Löschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)

View File

@@ -4,8 +4,11 @@
import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Download, Upload, Database, AlertTriangle, Loader2, KeyRound, Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
import { useRouter } from 'next/navigation';
export default function AdminSettings() {
@@ -16,26 +19,58 @@ export default function AdminSettings() {
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loading, setLoading] = useState(true);
// LicServer Config State
const [licUrl, setLicUrl] = useState('');
const [licKey, setLicKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [licSaving, setLicSaving] = useState(false);
const [licTesting, setLicTesting] = useState(false);
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
const [licMsg, setLicMsg] = useState('');
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
const router = useRouter();
useEffect(() => {
async function checkAccess() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
} else {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData, error: userError } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (userError || !userData || userData.role === 'verwaltung') {
router.push('/admin');
return;
}
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
// Load current LicServer config from DB
try {
const { data: licRow } = await supabase
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single();
if (licRow) {
setLicUrl(licRow.licserver_base_url || '');
setLicKey(licRow.licserver_api_key || '');
}
} catch (dbErr) {
console.error("Fehler beim Laden der LicServer-Einstellungen:", dbErr);
}
} catch (err) {
console.error("Fehler bei checkAccess in Einstellungen:", err);
} finally {
setLoading(false);
}
}
@@ -55,7 +90,7 @@ export default function AdminSettings() {
try {
const res = await fetch('/api/admin/db-backup');
if (!res.ok) throw new Error('Export fehlgeschlagen');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -149,11 +184,10 @@ export default function AdminSettings() {
</div>
{statusMsg && (
<div className={`p-4 rounded-lg text-sm border ${
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
<div className={`p-4 rounded-lg text-sm border ${statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
{statusMsg}
</div>
)}
@@ -170,8 +204,8 @@ export default function AdminSettings() {
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
</p>
</div>
<Button
onClick={handleExport}
<Button
onClick={handleExport}
disabled={exporting || importing}
className="w-full bg-primary hover:bg-primary/90 text-white"
>
@@ -200,9 +234,9 @@ export default function AdminSettings() {
<div className="space-y-3">
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
<input
type="file"
accept=".zip"
<input
type="file"
accept=".zip"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={exporting || importing}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
@@ -216,8 +250,8 @@ export default function AdminSettings() {
</div>
</div>
<Button
onClick={handleImport}
<Button
onClick={handleImport}
disabled={!selectedFile || exporting || importing}
variant="secondary"
className="w-full border border-slate-200 dark:border-white/10"
@@ -232,6 +266,125 @@ export default function AdminSettings() {
</div>
</div>
</div>
{/* LicServer Konfiguration */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<KeyRound className="w-5 h-5 text-violet-500" />
LicServer Konfiguration
</h2>
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
Verbindungseinstellungen zum CASPOS Lizenzserver.
</p>
</div>
{/* Status Message */}
{licMsg && (
<div className={`p-3 rounded-lg text-sm border ${licMsgType === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400'
: 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{licMsg}
</div>
)}
{/* Connection Test Result */}
{licStatus && (
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm border ${licStatus.ok
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
: 'bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400'
}`}>
{licStatus.ok
? <CheckCircle2 className="w-4 h-4 shrink-0" />
: <XCircle className="w-4 h-4 shrink-0" />}
{licStatus.message}
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="lic-url" className="text-xs font-medium flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
</Label>
<Input
id="lic-url"
value={licUrl}
onChange={e => setLicUrl(e.target.value)}
placeholder="http://192.168.178.174:9980"
className="text-sm font-mono"
/>
<p className="text-[10px] text-slate-400">URL des CASPOS Lizenzservers</p>
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="lic-key" className="text-xs font-medium flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
</Label>
<div className="relative">
<Input
id="lic-key"
type={showKey ? 'text' : 'password'}
value={licKey}
onChange={e => setLicKey(e.target.value)}
placeholder="Ihr X-Api-Key"
className="text-sm font-mono pr-10"
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
aria-label={showKey ? 'Key verbergen' : 'Key anzeigen'}
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-1">
<Button
id="lic-save-btn"
disabled={licSaving || licTesting}
onClick={async () => {
setLicSaving(true);
setLicMsg('');
setLicStatus(null);
const res = await saveLicServerConfig(licUrl, licKey);
setLicMsgType(res.success ? 'success' : 'error');
setLicMsg(res.success ? 'Konfiguration gespeichert.' : (res.error || 'Fehler beim Speichern'));
setLicSaving(false);
}}
className="bg-violet-600 hover:bg-violet-500 text-white"
>
{licSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
Speichern
</Button>
<Button
id="lic-test-btn"
variant="outline"
disabled={licSaving || licTesting}
onClick={async () => {
setLicTesting(true);
setLicStatus(null);
// Save first, then test
await saveLicServerConfig(licUrl, licKey);
const result = await testLicServerConnection();
setLicStatus(result);
setLicTesting(false);
}}
className="border-white/10"
>
{licTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wifi className="w-4 h-4 mr-2" />}
Verbindung testen
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { syncCompaniesFromLicServer } from '@/lib/actions/companies'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
// Check authorization header
const authHeader = req.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
// Secure the cron endpoint in production
if (process.env.NODE_ENV === 'production' && cronSecret) {
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
try {
const res = await syncCompaniesFromLicServer()
return NextResponse.json({ success: true, count: res.count })
} catch (err: any) {
console.error('[cron] Sync failed:', err)
return NextResponse.json({ error: err.message || 'Sync failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { syncCustomersFromLicServer } from '@/lib/actions/companies'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
// Check authorization header
const authHeader = req.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
// Secure the cron endpoint in production
if (process.env.NODE_ENV === 'production' && cronSecret) {
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
try {
const res = await syncCustomersFromLicServer()
return NextResponse.json({ success: true, count: res.count })
} catch (err: any) {
console.error('[cron] Customer sync failed:', err)
return NextResponse.json({ error: err.message || 'Sync failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { CustomerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
/**
* GET /api/licserver/partners/[id]/customers
*
* Proxy to LicServer GET /api-v1/partners/{id}/customers
* [id] must be a valid UUID.
* Query params forwarded: search, page, pageSize
*
* Secured: requires a valid Supabase session.
* base_url + api_key are read from DB settings (admin-configurable),
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// ── Auth guard ─────────────────────────────────────────────────────────
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── Load config from DB (falls back to env vars) ──────────────────────
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
console.error('[licserver] base_url or api_key not configured (DB or env)')
return NextResponse.json(
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
const { id } = await params
// Basic UUID format guard
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
}
// ── Forward query params ───────────────────────────────────────────────
const { searchParams } = req.nextUrl
const upstream = new URL(`${cfg.base_url}/api-v1/partners/${id}/customers`)
const search = searchParams.get('search')
const page = searchParams.get('page')
const pageSize = searchParams.get('pageSize')
if (search) upstream.searchParams.set('search', search)
if (page) upstream.searchParams.set('page', page)
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
// ── Proxy request ──────────────────────────────────────────────────────
try {
const res = await fetch(
upstream.toString(),
{
method: 'GET',
headers: {
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5_000),
}
)
if (!res.ok) {
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
return NextResponse.json(problem, { status: res.status })
}
const data: CustomerDtoPagedResult = await res.json()
return NextResponse.json(data)
} catch (err) {
console.error('[licserver] partner/:id/customers fetch error:', err)
return NextResponse.json(
{ error: 'LicServer nicht erreichbar' },
{ status: 502 }
)
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { PartnerDto, LicServerProblemDetails } from '@/lib/types/licserver'
/**
* GET /api/licserver/partners/[id]
*
* Proxy to LicServer GET /api-v1/partners/{id}
* [id] must be a valid UUID.
*
* Secured: requires a valid Supabase session.
* base_url + api_key are read from DB settings (admin-configurable),
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// ── Auth guard ─────────────────────────────────────────────────────────
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── Load config from DB (falls back to env vars) ──────────────────────
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
console.error('[licserver] base_url or api_key not configured (DB or env)')
return NextResponse.json(
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
const { id } = await params
// Basic UUID format guard
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
}
// ── Proxy request ──────────────────────────────────────────────────────
try {
const res = await fetch(
`${cfg.base_url}/api-v1/partners/${id}`,
{
method: 'GET',
headers: {
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5_000),
}
)
if (!res.ok) {
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
return NextResponse.json(problem, { status: res.status })
}
const data: PartnerDto = await res.json()
return NextResponse.json(data)
} catch (err) {
console.error('[licserver] partner/:id fetch error:', err)
return NextResponse.json(
{ error: 'LicServer nicht erreichbar' },
{ status: 502 }
)
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { PartnerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
/**
* GET /api/licserver/partners
*
* Proxy to LicServer GET /api-v1/partners
* Query params forwarded: search, page, pageSize
*
* Secured: requires a valid Supabase session.
* base_url + api_key are read from DB settings (admin-configurable),
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
*/
export async function GET(req: NextRequest) {
// ── Auth guard ─────────────────────────────────────────────────────────
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── Load config from DB (falls back to env vars) ──────────────────────
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
console.error('[licserver] base_url or api_key not configured (DB or env)')
return NextResponse.json(
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
// ── Forward query params ───────────────────────────────────────────────
const { searchParams } = req.nextUrl
const upstream = new URL(`${cfg.base_url}/api-v1/partners`)
const search = searchParams.get('search')
const page = searchParams.get('page')
const pageSize = searchParams.get('pageSize')
if (search) upstream.searchParams.set('search', search)
if (page) upstream.searchParams.set('page', page)
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
// ── Proxy request ──────────────────────────────────────────────────────
try {
const res = await fetch(upstream.toString(), {
method: 'GET',
headers: {
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
// Internal container network — short timeout
signal: AbortSignal.timeout(5_000),
})
if (!res.ok) {
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
return NextResponse.json(problem, { status: res.status })
}
const data: PartnerDtoPagedResult = await res.json()
return NextResponse.json(data)
} catch (err) {
console.error('[licserver] partners fetch error:', err)
return NextResponse.json(
{ error: 'LicServer nicht erreichbar' },
{ status: 502 }
)
}
}

View File

@@ -8,6 +8,8 @@ import { renderToBuffer } from '@react-pdf/renderer';
import React from 'react';
import { InvoicePDF } from '@/components/invoice-pdf';
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
import { validateWizardSelections } from '@/lib/actions/validation';
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
const year = new Date().getFullYear();
@@ -49,6 +51,13 @@ export async function POST(request: Request) {
const products = await getProducts();
const categories = await getCategories();
// --- Backend-Validierung: alle Kategorie-Flags für jede Kasse prüfen ---
for (const item of items) {
if (item.selections) {
validateWizardSelections(item.selections, products, categories);
}
}
// Split items into purchase and subscription
const purchaseItems = items.filter(
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'

View File

@@ -16,7 +16,7 @@ export default function Datenschutz() {
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
<Lock className="w-6 h-6" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Datenschutzerklärung
</h1>
<p className="text-slate-400">Informationen über die Verarbeitung Ihrer personenbezogenen Daten.</p>

View File

@@ -16,7 +16,7 @@ export default function Impressum() {
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
<Shield className="w-6 h-6" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Impressum
</h1>
<p className="text-slate-400">Angaben gemäß § 5 TMG.</p>

View File

@@ -70,37 +70,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
{/* Background Decorative Animated Blobs */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-[600px] pointer-events-none -z-10 overflow-hidden">
<motion.div
animate={{
scale: [1, 1.15, 0.95, 1],
rotate: [0, 90, 180, 270, 360],
x: [0, 40, -30, 0],
y: [0, -20, 50, 0]
}}
transition={{
duration: 20,
repeat: Infinity,
ease: "easeInOut"
}}
className="absolute -top-[10%] left-[10%] w-[350px] sm:w-[500px] h-[350px] sm:h-[500px] bg-blue-600/15 blur-[120px] sm:blur-[160px] rounded-full"
/>
<motion.div
animate={{
scale: [1, 0.9, 1.1, 1],
rotate: [0, -90, -180, -270, -360],
x: [0, -30, 40, 0],
y: [0, 50, -30, 0]
}}
transition={{
duration: 25,
repeat: Infinity,
ease: "easeInOut"
}}
className="absolute -top-[5%] right-[15%] w-[300px] sm:w-[450px] h-[300px] sm:h-[450px] bg-purple-600/10 blur-[100px] sm:blur-[140px] rounded-full"
/>
</div>
{/* Hero Section */}
<section className="w-full pt-16 pb-12 md:pt-28 md:pb-20 px-4 relative flex flex-col items-center">
@@ -110,27 +80,14 @@ export function HomeClient({ initialUser }: HomeClientProps) {
variants={containerVariants}
className="container max-w-6xl mx-auto flex flex-col items-center text-center space-y-8"
>
{/* Badge */}
<motion.div
variants={itemVariants}
className="inline-flex items-center gap-2 rounded-full bg-blue-500/10 border border-blue-500/20 px-4 py-1.5 text-xs sm:text-sm text-blue-400 font-semibold tracking-wide backdrop-blur-md shadow-[0_0_15px_rgba(59,130,246,0.1)]"
>
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
>
<Zap className="w-3.5 h-3.5 text-blue-400 fill-blue-400/20" />
</motion.div>
CASPOS Lizenz-Portal
</motion.div>
{/* Headline */}
<div className="space-y-4 max-w-4xl">
<motion.h1
variants={itemVariants}
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight bg-clip-text text-transparent bg-gradient-to-b from-white via-slate-100 to-slate-400"
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight text-white"
>
CASPOS Software <span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-500 via-indigo-400 to-purple-500 font-black">Lizenzen & Module</span>
CASPOS Software <span className="text-blue-500 font-black">Lizenzen & Module</span>
</motion.h1>
<motion.p
variants={itemVariants}
@@ -146,7 +103,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
className="flex flex-col sm:flex-row items-center justify-center gap-4 w-full max-w-md pt-4"
>
<Link href="/order" className="w-full sm:w-auto">
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-bold transition-all duration-300 hover:scale-[1.02] bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-[0_0_25px_rgba(37,99,235,0.25)] hover:shadow-[0_0_35px_rgba(37,99,235,0.45)] border-none rounded-xl">
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-semibold bg-blue-600 hover:bg-blue-500 text-white border border-blue-700/50 rounded-xl transition-all duration-200">
Lizenz anfragen <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
@@ -163,19 +120,17 @@ export function HomeClient({ initialUser }: HomeClientProps) {
variants={itemVariants}
className="w-full max-w-3xl pt-12"
>
<div className="relative p-6 rounded-2xl border border-slate-900/80 bg-slate-950/40 backdrop-blur-xl shadow-2xl">
{/* Card border glow decoration */}
<div className="absolute inset-0 rounded-2xl border border-blue-500/10 pointer-events-none" />
<p className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
<div className="relative p-6 rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur-md shadow-lg">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
Mein Dashboard Schnellzugriff
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Link href="/my-customers" className="group">
<motion.div
whileHover={{ y: -4, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
@@ -192,9 +147,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
<Link href="/my-orders" className="group">
<motion.div
whileHover={{ y: -4, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
@@ -223,10 +178,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-indigo-600/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
<div className="text-center space-y-3 mb-16">
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 border border-indigo-500/20 px-3 py-1 rounded-full">
<Sparkles className="w-3 h-3" />CASPOS Sites
</div>
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-white">
Workspace-Zentrale
</h2>
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
@@ -237,11 +189,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Card 1 */}
<a href="https://support.teamzone.softengine.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
<MessageSquare className="h-6 w-6" />
@@ -255,11 +205,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
{/* Card 2 */}
<a href="https://wiki.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
<BookOpen className="h-6 w-6" />
@@ -273,11 +221,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
{/* Card 3 */}
<a href="https://support.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-purple-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
<LifeBuoy className="h-6 w-6" />
@@ -296,17 +242,14 @@ export function HomeClient({ initialUser }: HomeClientProps) {
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30">
<div className="container max-w-5xl mx-auto px-4">
<div className="text-center space-y-3 mb-16">
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
<Activity className="w-3 h-3" /> Ablauf
</div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center text-white">
Wie funktioniert die Bereitstellung?
</h2>
</div>
<div className="relative">
{/* Connection line for Desktop */}
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] bg-gradient-to-r from-blue-500/10 via-blue-500/30 to-blue-500/10 z-0" />
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] border-t border-dashed border-slate-800 z-0" />
<div className="grid gap-8 md:grid-cols-3 relative z-10">
{/* Step 1 */}

View File

@@ -110,7 +110,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
<nav className="hidden md:flex gap-6 items-center">
<Link
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
href="https://wiki.caspos.de"
href="https://support.teamzone.softengine.de"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -111,8 +111,8 @@ export function SetupWizard() {
{/* Step Indicator Header */}
<div className="flex items-center justify-between mb-8 px-2">
<div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight text-gradient bg-gradient-to-r from-blue-400 to-indigo-400">CASPOS</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-medium">Initialisierung</span>
<span className="text-xl font-bold tracking-tight text-blue-400">CASPOS</span>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">Initialisierung</span>
</div>
<div className="flex gap-1.5">
{[1, 2, 3].map((s) => (

View File

@@ -15,6 +15,8 @@ import { StepCustomer } from './wizard/step-customer'
import { StepBilling } from './wizard/step-billing'
import { StepSoftware } from './wizard/step-software'
import { StepSummary } from './wizard/step-summary'
import { SummarySidebar } from './wizard/summary-sidebar'
import { LicenseLookupPanel } from './wizard/license-lookup-panel'
type CategorySelection = {
productId: string | null // selected product in this category
@@ -593,31 +595,54 @@ export function OrderWizard({
function selectProduct(catId: string, productId: string) {
setSelections(prev => {
const cat = categories.find(c => c.id === catId)
// Build updated selection for this category
let updatedCatSel: CategorySelection
if (cat?.allow_multiselect) {
const currentIds = prev[catId]?.productIds || []
const nextIds = currentIds.includes(productId)
? currentIds.filter(id => id !== productId)
: [...currentIds, productId]
return {
...prev,
[catId]: {
productId: nextIds[0] || null,
productIds: nextIds,
moduleIds: prev[catId]?.moduleIds || []
}
updatedCatSel = {
productId: nextIds[0] || null,
productIds: nextIds,
moduleIds: prev[catId]?.moduleIds || []
}
} else {
const isCurrentlyChecked = prev[catId]?.productId === productId
const nextId = isCurrentlyChecked ? null : productId
return {
...prev,
[catId]: {
productId: nextId,
productIds: nextId ? [nextId] : [],
moduleIds: []
}
updatedCatSel = {
productId: nextId,
productIds: nextId ? [nextId] : [],
moduleIds: []
}
}
// If this category resets others, rebuild all other categories to their initial state
if (cat?.resets_others) {
const resetSels: Record<string, CategorySelection> = {}
categories.forEach(c => {
if (c.id === catId) {
resetSels[c.id] = updatedCatSel
} else {
const isVisible = selectedBillingInterval === 'one_time' ? c.show_in_kauf !== false : c.show_in_abo !== false
if (!isVisible || c.allow_multiselect || c.preselect === false) {
resetSels[c.id] = { productId: null, productIds: [], moduleIds: [] }
} else {
const first = products.find(p =>
p.category_id === c.id &&
(selectedBillingInterval === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false)
)
resetSels[c.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] }
}
}
})
// Also reset module quantities for other categories
setModuleQuantities({})
return resetSels
}
return { ...prev, [catId]: updatedCatSel }
})
}
@@ -640,10 +665,12 @@ export function OrderWizard({
}
return (
<div className="max-w-5xl mx-auto py-12 px-4">
{/* Progress Stepper */}
<div className={`mx-auto px-4 ${step === 3 ? 'max-w-screen-2xl' : 'max-w-5xl'}`}>
{/* Global Stepper for Steps 1, 2, 4 */}
{step !== 3 && (
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
<div className="pt-12 pb-6">
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
</div>
)}
<AnimatePresence mode="wait">
@@ -707,45 +734,72 @@ export function OrderWizard({
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
<StepSoftware
visibleCategories={visibleCategories}
products={products}
selections={selections}
selectProduct={selectProduct}
isProductDisabled={isProductDisabled}
isModuleDisabled={isModuleDisabled}
toggleModule={toggleModule}
moduleQuantities={moduleQuantities}
setModuleQuantities={setModuleQuantities}
selectedBillingInterval={selectedBillingInterval}
billingLabel={billingLabel}
billingBadgeClass={billingBadgeClass}
oneTimeTotal={oneTimeTotal}
monthlyTotal={monthlyTotal}
oneTimeNet={oneTimeNet}
oneTimeTax={oneTimeTax}
oneTimeGross={oneTimeGross}
monthlyNet={monthlyNet}
monthlyTax={monthlyTax}
monthlyGross={monthlyGross}
updatePriceModifier={updatePriceModifier}
allCategoriesFilled={allCategoriesFilled}
productValidationErrors={productValidationErrors}
basketItems={basketItems}
editingIdx={editingIdx}
editBasketItem={editBasketItem}
deleteBasketItem={deleteBasketItem}
deviceName={deviceName}
setDeviceName={setDeviceName}
addToBasket={addToBasket}
isNextStepDisabled={isNextStepDisabled}
hasActiveSelection={hasActiveSelection}
nextStep={nextStep}
prevStep={prevStep}
/>
<div className="grid grid-cols-1 lg:grid-cols-6 gap-6 items-start">
{/* FAR LEFT: License Lookup Panel (sticky) */}
<div className="lg:col-span-2 sticky top-6">
<LicenseLookupPanel />
</div>
{/* CENTER: Sticky stepper + scrollable categories */}
<div className="lg:col-span-2 space-y-0">
{/* Sticky stepper header — only covers center column */}
<div className="sticky top-0 z-20 bg-slate-950/80 backdrop-blur-md pb-4 pt-6 border-b border-white/5 mb-6">
<p className="text-center text-slate-400 text-sm mb-4">Schritt 3 von 4 Software konfigurieren</p>
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
</div>
{/* Scrollable category content */}
<StepSoftware
visibleCategories={visibleCategories}
products={products}
selections={selections}
selectProduct={selectProduct}
isProductDisabled={isProductDisabled}
isModuleDisabled={isModuleDisabled}
toggleModule={toggleModule}
moduleQuantities={moduleQuantities}
setModuleQuantities={setModuleQuantities}
selectedBillingInterval={selectedBillingInterval}
billingLabel={billingLabel}
billingBadgeClass={billingBadgeClass}
/>
</div>
{/* RIGHT: Summary sidebar (sticky) */}
<div className="lg:col-span-2 sticky top-6">
<SummarySidebar
visibleCategories={visibleCategories}
selections={selections}
products={products}
moduleQuantities={moduleQuantities}
billingLabel={billingLabel}
oneTimeTotal={oneTimeTotal}
monthlyTotal={monthlyTotal}
oneTimeNet={oneTimeNet}
oneTimeTax={oneTimeTax}
oneTimeGross={oneTimeGross}
monthlyNet={monthlyNet}
monthlyTax={monthlyTax}
monthlyGross={monthlyGross}
updatePriceModifier={updatePriceModifier}
allCategoriesFilled={allCategoriesFilled}
productValidationErrors={productValidationErrors}
basketItems={basketItems}
editingIdx={editingIdx}
editBasketItem={editBasketItem}
deleteBasketItem={deleteBasketItem}
deviceName={deviceName}
setDeviceName={setDeviceName}
addToBasket={addToBasket}
isNextStepDisabled={isNextStepDisabled}
hasActiveSelection={hasActiveSelection}
nextStep={nextStep}
prevStep={prevStep}
/>
</div>
</div>
</motion.div>
)}

View File

@@ -0,0 +1,480 @@
'use client'
import React, { useState, useTransition, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
KeyRound,
Search,
Loader2,
CheckCircle2,
AlertTriangle,
XCircle,
Building2,
User,
CalendarDays,
Package,
ShieldCheck,
Hash,
ChevronDown,
ChevronUp,
Fingerprint,
} from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { lookupLicenseFromLicServer } from '@/lib/actions/licserver-config'
// ─── MOCK DATA (until real LicServer API is wired up) ───────────────────────
const MOCK_LICENSES: Record<string, LicenseInfo> = {
'CAS-2023-PRO-00123': {
licenseKey: 'CAS-2023-PRO-00123',
status: 'active',
product: 'CAS genesisWorld Professional',
edition: 'Professional',
version: '14.1.2',
seats: 10,
customer: 'Mustermann GmbH',
contact: 'Max Mustermann',
issuedAt: '2023-04-01',
expiresAt: '2026-03-31',
maintenanceUntil: '2025-03-31',
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client'],
},
'CAS-2020-STD-00456': {
licenseKey: 'CAS-2020-STD-00456',
status: 'expired',
product: 'CAS genesisWorld Standard',
edition: 'Standard',
version: '12.0.0',
seats: 5,
customer: 'Beispiel AG',
contact: 'Erika Muster',
issuedAt: '2020-01-15',
expiresAt: '2023-01-14',
maintenanceUntil: '2022-01-14',
modules: ['CRM'],
},
'CAS-2024-ENT-00789': {
licenseKey: 'CAS-2024-ENT-00789',
status: 'active',
product: 'CAS genesisWorld Enterprise',
edition: 'Enterprise',
version: '15.0.0',
seats: 50,
customer: 'Tech Solutions GmbH & Co. KG',
contact: 'Julia Schneider',
issuedAt: '2024-01-01',
expiresAt: '2026-12-31',
maintenanceUntil: '2026-12-31',
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client', 'AI Assistant', 'Analytics Pro'],
},
}
// ─── TYPES ───────────────────────────────────────────────────────────────────
export type LicenseStatus = 'active' | 'expired' | 'invalid' | 'suspended'
export interface LicenseInfo {
licenseKey: string
status: LicenseStatus
product: string
edition: string
version: string
seats: number
customer: string
contact: string
issuedAt: string
expiresAt: string
maintenanceUntil: string
modules: string[]
}
interface LicenseLookupPanelProps {
/** Called when a valid license is found so the wizard can prefill fields */
onLicenseResolved?: (info: LicenseInfo) => void
}
// ─── HELPERS ─────────────────────────────────────────────────────────────────
function statusConfig(status: LicenseStatus) {
switch (status) {
case 'active':
return {
label: 'Aktiv',
icon: <CheckCircle2 className="w-3.5 h-3.5" />,
cls: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
ring: 'border-emerald-500/30',
glow: 'shadow-emerald-500/10',
}
case 'expired':
return {
label: 'Abgelaufen',
icon: <AlertTriangle className="w-3.5 h-3.5" />,
cls: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
ring: 'border-amber-500/30',
glow: 'shadow-amber-500/10',
}
case 'suspended':
return {
label: 'Gesperrt',
icon: <XCircle className="w-3.5 h-3.5" />,
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
ring: 'border-red-500/30',
glow: 'shadow-red-500/10',
}
default:
return {
label: 'Ungültig',
icon: <XCircle className="w-3.5 h-3.5" />,
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
ring: 'border-red-500/30',
glow: 'shadow-red-500/10',
}
}
}
function fmt(dateStr: string) {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})
}
// ─── LOOKUP (tries real LicServer API call, falls back to MOCK in dev) ──────────────────────
async function lookupLicense(key: string): Promise<LicenseInfo | null> {
try {
return await lookupLicenseFromLicServer(key)
} catch (err) {
console.error('Lookup failed, falling back to mock details:', err)
return MOCK_LICENSES[key.trim().toUpperCase()] ?? null
}
}
// ─── MAIN COMPONENT ───────────────────────────────────────────────────────────
export function LicenseLookupPanel({ onLicenseResolved }: LicenseLookupPanelProps) {
const [licenseKey, setLicenseKey] = useState('')
const [result, setResult] = useState<LicenseInfo | null>(null)
const [notFound, setNotFound] = useState(false)
const [isPending, startTransition] = useTransition()
const [expanded, setExpanded] = useState(true)
const [modulesOpen, setModulesOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const handleSearch = () => {
if (!licenseKey.trim()) return
setResult(null)
setNotFound(false)
startTransition(async () => {
const info = await lookupLicense(licenseKey)
if (info) {
setResult(info)
onLicenseResolved?.(info)
} else {
setNotFound(true)
}
})
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSearch()
}
const handleClear = () => {
setLicenseKey('')
setResult(null)
setNotFound(false)
inputRef.current?.focus()
}
const sc = result ? statusConfig(result.status) : null
return (
<div className="rounded-2xl border border-white/10 bg-slate-900/60 backdrop-blur-md overflow-hidden shadow-xl">
{/* ── Panel Header (toggle) ─────────────────────────────────────────── */}
<button
id="license-lookup-toggle"
onClick={() => setExpanded(v => !v)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
<KeyRound className="w-4 h-4 text-violet-400" />
</div>
<div className="text-left">
<p className="text-sm font-semibold text-white">Alte Lizenznummer eingeben</p>
<p className="text-[11px] text-slate-400">Upgrade / Migration</p>
</div>
</div>
{expanded
? <ChevronUp className="w-4 h-4 text-slate-400" />
: <ChevronDown className="w-4 h-4 text-slate-400" />}
</button>
{/* ── Collapsible Body ──────────────────────────────────────────────── */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="px-5 pb-5 space-y-4 border-t border-white/5">
{/* ── Input + Search Button ──────────────────────────────── */}
<div className="pt-4">
<p className="text-xs text-slate-400 mb-2 leading-relaxed">
Geben Sie Ihre bisherige CAS-Lizenznummer ein,
um Lizenzinformationen automatisch abzurufen.
</p>
<div className="flex items-center gap-2">
{/* Input */}
<div className="relative flex-1">
<Fingerprint className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-violet-400 pointer-events-none" />
<Input
ref={inputRef}
id="license-lookup-input"
value={licenseKey}
onChange={e => {
setLicenseKey(e.target.value)
setResult(null)
setNotFound(false)
}}
onKeyDown={handleKeyDown}
placeholder="z. B. CAS-2023-PRO-00123"
className="pl-9 pr-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500
focus:border-violet-500/60 focus:ring-violet-500/20 rounded-xl text-sm h-10"
/>
{licenseKey && (
<button
onClick={handleClear}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors"
aria-label="Eingabe löschen"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
{/* Search Button */}
<button
id="license-lookup-search-btn"
onClick={handleSearch}
disabled={isPending || !licenseKey.trim()}
className="flex items-center gap-1.5 px-3.5 h-10 rounded-xl bg-violet-600 hover:bg-violet-500
disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium
transition-all shadow-lg shadow-violet-500/20 shrink-0"
>
{isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <Search className="w-4 h-4" />}
Prüfen
</button>
</div>
{/* Demo quick-fill hints */}
<p className="text-[10px] text-slate-500 mt-1.5">
Demo:&nbsp;
<span
className="font-mono text-violet-400/80 cursor-pointer hover:text-violet-400 transition-colors"
onClick={() => setLicenseKey('CAS-2023-PRO-00123')}
>
CAS-2023-PRO-00123
</span>
{' · '}
<span
className="font-mono text-amber-400/80 cursor-pointer hover:text-amber-400 transition-colors"
onClick={() => setLicenseKey('CAS-2020-STD-00456')}
>
CAS-2020-STD-00456
</span>
</p>
</div>
{/* ── Animated Result Area ──────────────────────────────── */}
<AnimatePresence mode="wait">
{/* Loading skeleton */}
{isPending && (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-2"
>
{[1, 2, 3].map(i => (
<div key={i} className="h-7 rounded-lg bg-white/5 animate-pulse" />
))}
<p className="text-center text-[11px] text-slate-500 animate-pulse">
Lizenzserver wird abgefragt
</p>
</motion.div>
)}
{/* Not Found */}
{notFound && !isPending && (
<motion.div
key="notfound"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex items-start gap-2.5 p-3 rounded-xl bg-red-500/10 border border-red-500/30"
>
<XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-red-300">Lizenz nicht gefunden</p>
<p className="text-xs text-red-400/70 mt-0.5">
Die eingegebene Nummer wurde im Lizenzsystem nicht gefunden.
Bitte prüfen Sie die Eingabe.
</p>
</div>
</motion.div>
)}
{/* Result Card */}
{result && !isPending && sc && (
<motion.div
key="result"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={`rounded-xl border ${sc.ring} bg-slate-900/80 shadow-lg ${sc.glow} overflow-hidden`}
>
{/* Status Bar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-white/5">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-slate-300" />
<span className="text-xs font-mono text-slate-300 truncate max-w-[140px]">
{result.licenseKey}
</span>
</div>
<Badge className={`text-[10px] px-2 py-0.5 flex items-center gap-1 border ${sc.cls}`}>
{sc.icon}
{sc.label}
</Badge>
</div>
{/* Info Rows */}
<div className="px-4 py-3 space-y-2.5">
{/* Product */}
<div className="flex items-start gap-2">
<Package className="w-3.5 h-3.5 text-violet-400 mt-0.5 shrink-0" />
<div>
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Produkt</p>
<p className="text-xs font-semibold text-white leading-tight">{result.product}</p>
<p className="text-[10px] text-slate-400">
{result.edition} · v{result.version} · {result.seats} Arbeitsplätze
</p>
</div>
</div>
{/* Customer */}
<div className="flex items-start gap-2">
<Building2 className="w-3.5 h-3.5 text-blue-400 mt-0.5 shrink-0" />
<div>
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Lizenznehmer</p>
<p className="text-xs font-semibold text-white leading-tight">{result.customer}</p>
<p className="text-[10px] text-slate-400 flex items-center gap-1">
<User className="w-2.5 h-2.5" />
{result.contact}
</p>
</div>
</div>
{/* Date Grid */}
<div className="grid grid-cols-2 gap-2 pt-1">
<div className="rounded-lg bg-white/5 px-2.5 py-2">
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
<CalendarDays className="w-2.5 h-2.5" /> Ausgestellt
</p>
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.issuedAt)}</p>
</div>
<div className="rounded-lg bg-white/5 px-2.5 py-2">
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
<CalendarDays className="w-2.5 h-2.5" /> Läuft ab
</p>
<p className={`text-xs font-medium mt-0.5 ${result.status === 'expired' ? 'text-amber-400' : 'text-white'}`}>
{fmt(result.expiresAt)}
</p>
</div>
<div className="rounded-lg bg-white/5 px-2.5 py-2 col-span-2">
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
<Hash className="w-2.5 h-2.5" /> Wartung bis
</p>
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.maintenanceUntil)}</p>
</div>
</div>
{/* Modules (collapsible) */}
{result.modules.length > 0 && (
<div>
<button
onClick={() => setModulesOpen(v => !v)}
className="w-full flex items-center justify-between text-[10px] text-slate-400
hover:text-white transition-colors pt-1"
>
<span className="uppercase tracking-wide">
Lizenzierte Module ({result.modules.length})
</span>
{modulesOpen
? <ChevronUp className="w-3 h-3" />
: <ChevronDown className="w-3 h-3" />}
</button>
<AnimatePresence>
{modulesOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="flex flex-wrap gap-1 pt-2">
{result.modules.map(m => (
<span
key={m}
className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/15
border border-violet-500/30 text-violet-300"
>
{m}
</span>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* Bottom hint */}
{result.status === 'active' && (
<div className="px-4 pb-3">
<p className="text-[10px] text-emerald-400/70 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Lizenz erkannt Daten können für die Bestellung übernommen werden.
</p>
</div>
)}
{result.status === 'expired' && (
<div className="px-4 pb-3">
<p className="text-[10px] text-amber-400/70 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Lizenz abgelaufen Upgrade empfohlen.
</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -17,16 +17,16 @@ export function ProgressStepper({ step, basketItemsCount }: ProgressStepperProps
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
step >= s
? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
? 'bg-blue-600 text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
: 'bg-slate-800 text-slate-400'
}`}
>
{step > s ? <Check className="w-5 h-5" /> : s}
</div>
<span className={`text-xs font-semibold ${step >= s ? 'text-primary' : 'text-slate-500'} flex items-center gap-1`}>
<span className={`text-xs font-semibold ${step >= s ? 'text-blue-400' : 'text-slate-500'} flex items-center gap-1`}>
{s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
{s === 3 && basketItemsCount > 0 && (
<span className="bg-primary text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold px-1.5 leading-none">
<span className="bg-blue-600 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold px-1.5 leading-none">
{basketItemsCount}
</span>
)}

View File

@@ -11,7 +11,6 @@ import { Badge } from '@/components/ui/badge'
import { ShoppingCart, Check, AlertCircle } from 'lucide-react'
import * as Icons from 'lucide-react'
import { Category, Product, CategorySelection } from '@/lib/types'
import { SummarySidebar } from './summary-sidebar'
interface StepSoftwareProps {
visibleCategories: Category[]
@@ -26,30 +25,6 @@ interface StepSoftwareProps {
selectedBillingInterval: 'one_time' | 'monthly'
billingLabel: (interval: string) => string
billingBadgeClass: (interval: string) => string
// Sidebar props
oneTimeTotal: number
monthlyTotal: number
oneTimeNet: number
oneTimeTax: number
oneTimeGross: number
monthlyNet: number
monthlyTax: number
monthlyGross: number
updatePriceModifier: { label?: string }
allCategoriesFilled: boolean
productValidationErrors: string[]
basketItems: any[]
editingIdx: number | null
editBasketItem: (idx: number) => void
deleteBasketItem: (idx: number) => void
deviceName: string
setDeviceName: (name: string) => void
addToBasket: () => void
isNextStepDisabled: boolean
hasActiveSelection: boolean
nextStep: () => void
prevStep: () => void
}
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
@@ -71,44 +46,19 @@ export function StepSoftware({
selectedBillingInterval,
billingLabel,
billingBadgeClass,
oneTimeTotal,
monthlyTotal,
oneTimeNet,
oneTimeTax,
oneTimeGross,
monthlyNet,
monthlyTax,
monthlyGross,
updatePriceModifier,
allCategoriesFilled,
productValidationErrors,
basketItems,
editingIdx,
editBasketItem,
deleteBasketItem,
deviceName,
setDeviceName,
addToBasket,
isNextStepDisabled,
hasActiveSelection,
nextStep,
prevStep,
}: StepSoftwareProps) {
return (
<div className="grid md:grid-cols-3 gap-6">
{/* Left: Category sections */}
<div className="md:col-span-2 space-y-8">
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white">
<ShoppingCart className="w-6 h-6 text-primary" />
Software wählen
</CardTitle>
<CardDescription className="text-slate-300">
Wählen Sie pro Kategorie mindestens einen Artikel aus.
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white">
<ShoppingCart className="w-6 h-6 text-blue-400" />
Software wählen
</CardTitle>
<CardDescription className="text-slate-300">
Wählen Sie pro Kategorie mindestens einen Artikel aus.
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
{visibleCategories.map((cat, idx) => {
const catProducts = products.filter(p => {
if (p.category_id !== cat.id) return false
@@ -339,40 +289,7 @@ export function StepSoftware({
</div>
)
})}
</CardContent>
</Card>
</div>
{/* Right: Summary sidebar */}
<SummarySidebar
visibleCategories={visibleCategories}
selections={selections}
products={products}
moduleQuantities={moduleQuantities}
billingLabel={billingLabel}
oneTimeTotal={oneTimeTotal}
monthlyTotal={monthlyTotal}
oneTimeNet={oneTimeNet}
oneTimeTax={oneTimeTax}
oneTimeGross={oneTimeGross}
monthlyNet={monthlyNet}
monthlyTax={monthlyTax}
monthlyGross={monthlyGross}
updatePriceModifier={updatePriceModifier}
allCategoriesFilled={allCategoriesFilled}
productValidationErrors={productValidationErrors}
basketItems={basketItems}
editingIdx={editingIdx}
editBasketItem={editBasketItem}
deleteBasketItem={deleteBasketItem}
deviceName={deviceName}
setDeviceName={setDeviceName}
addToBasket={addToBasket}
isNextStepDisabled={isNextStepDisabled}
hasActiveSelection={hasActiveSelection}
nextStep={nextStep}
prevStep={prevStep}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -69,12 +69,11 @@ export function SummarySidebar({
prevStep,
}: SummarySidebarProps) {
return (
<div className="space-y-6">
<Card className="glass-dark border-primary/20 sticky top-[20vh] backdrop-blur-lg bg-slate-950/75">
<CardHeader>
<CardTitle className="text-white">Zusammenfassung</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Card className="glass-dark border-primary/20 backdrop-blur-lg bg-slate-950/75 flex flex-col max-h-[80vh]">
<CardHeader className="shrink-0">
<CardTitle className="text-white">Zusammenfassung</CardTitle>
</CardHeader>
<CardContent className="space-y-4 flex-1 overflow-y-auto subpixel-antialiased scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
{visibleCategories.map(cat => {
const sel = selections[cat.id]
const selectedProds: Product[] = []
@@ -261,7 +260,7 @@ export function SummarySidebar({
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-3">
<CardFooter className="flex flex-col gap-3 shrink-0">
<div className="w-full space-y-2">
<Label htmlFor="device-name" className="text-xs text-slate-400">Kassenname (optional)</Label>
<Input
@@ -293,7 +292,6 @@ export function SummarySidebar({
Zurück zum Abrechnungsmodell
</Button>
</CardFooter>
</Card>
</div>
</Card>
)
}

View File

@@ -1,6 +1,7 @@
'use server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath, unstable_noStore as noStore } from 'next/cache'
import { getLicServerConfig, getLicServerConfigSystem } from './licserver-config'
export async function getCompanies() {
noStore()
@@ -219,3 +220,135 @@ export async function adminDeleteEndCustomer(id: string) {
return true
}
export async function syncCompaniesFromLicServer() {
const admin = createAdminClient()
const cfg = await getLicServerConfigSystem()
if (!cfg.base_url || !cfg.api_key) {
throw new Error('LicServer ist nicht konfiguriert.')
}
const res = await fetch(`${cfg.base_url}/api-v1/partners?pageSize=1000`, {
headers: { 'X-Api-Key': cfg.api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) {
throw new Error(`Lizenzserver antwortete mit Fehler: HTTP ${res.status}`)
}
const result = await res.json()
const partners = result.items || []
if (partners.length === 0) {
return { success: true, count: 0 }
}
const upsertRows = partners.map((p: any) => ({
id: p.id,
name: p.name || 'Unbenannt',
email: p.email || null,
}))
const { error } = await admin
.from('companies')
.upsert(upsertRows, { onConflict: 'id' })
if (error) throw error
revalidatePath('/admin/companies')
revalidatePath('/admin/users')
return { success: true, count: partners.length }
}
export async function syncCustomersFromLicServer() {
const admin = createAdminClient()
const cfg = await getLicServerConfigSystem()
if (!cfg.base_url || !cfg.api_key) {
throw new Error('LicServer ist nicht konfiguriert.')
}
// 1. Get all local companies
const { data: companies, error: compErr } = await admin
.from('companies')
.select('id')
if (compErr) throw compErr
if (!companies || companies.length === 0) {
return { success: true, count: 0 }
}
let totalSynced = 0
const allUpsertRows: any[] = []
// 2. Fetch customers for each company
for (const company of companies) {
try {
// Workaround for LicServer crash on null/empty search parameter:
// We query using common vowels/umlauts in parallel and de-duplicate locally.
const searchChars = ['a', 'e', 'i', 'o', 'u', 'ä', 'ö', 'ü', 'y']
const customerMap = new Map<string, any>()
await Promise.all(
searchChars.map(async char => {
try {
const res = await fetch(`${cfg.base_url}/api-v1/partners/${company.id}/customers?pageSize=1000&search=${encodeURIComponent(char)}`, {
headers: { 'X-Api-Key': cfg.api_key!, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (res.ok) {
const result = await res.json()
const items = result.items || []
for (const c of items) {
customerMap.set(c.id, c)
}
}
} catch (e: any) {
console.error(`[sync-customers] Error fetching with search=${char} for partner ${company.id}:`, e.message || e)
}
})
)
const customers = Array.from(customerMap.values())
for (const c of customers) {
const streetCombined = [c.street, c.houseNumber, c.houseNumberAddon]
.filter(Boolean)
.map(s => s.trim())
.join(' ')
allUpsertRows.push({
id: c.id,
partner_id: company.id,
company_name: c.name || 'Unbenannt',
street: streetCombined || null,
zip: c.zipCode || null,
city: c.city || null,
email: c.email || null,
is_anonymized: false,
})
}
} catch (e: any) {
console.error(`[sync-customers] Error fetching customers for partner ${company.id}:`, e.message || e)
}
}
if (allUpsertRows.length > 0) {
const { error: upsertErr } = await admin
.from('end_customers')
.upsert(allUpsertRows, { onConflict: 'id' })
if (upsertErr) throw upsertErr
totalSynced = allUpsertRows.length
}
revalidatePath('/my-customers')
revalidatePath('/order')
revalidatePath('/admin/companies')
return { success: true, count: totalSynced }
}

View File

@@ -0,0 +1,151 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
export interface LicServerConfig {
base_url: string | null
api_key: string | null
}
/**
* Read LicServer config from the settings table.
* Falls back to environment variables if DB values are empty.
* Returns null config for unauthenticated callers.
*/
export async function getLicServerConfig(): Promise<LicServerConfig> {
const supabase = await createClient()
// Auth guard — never expose config to unauthenticated callers
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { base_url: null, api_key: null }
const { data } = await supabase
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single()
return {
base_url: data?.licserver_base_url || process.env.LICSERVER_BASE_URL || null,
api_key: data?.licserver_api_key || process.env.LICSERVER_API_KEY || null,
}
}
/**
* Save LicServer config. Admin-only — checks role before writing.
*/
export async function saveLicServerConfig(
base_url: string,
api_key: string
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
// Auth + role check
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { success: false, error: 'Nicht angemeldet' }
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
// Only 'admin' exists in schema — 'superadmin' removed
if (!userData || userData.role !== 'admin') {
return { success: false, error: 'Keine Berechtigung (nur Admins)' }
}
const { error } = await supabase
.from('settings')
.upsert({
id: 'licserver',
licserver_base_url: base_url.trim() || null,
licserver_api_key: api_key.trim() || null,
updated_at: new Date().toISOString(),
})
if (error) return { success: false, error: error.message }
return { success: true }
}
/**
* Test the connection with current config.
* Calls GET /api-v1/partners?pageSize=1 and returns true if 200.
*/
export async function testLicServerConnection(): Promise<{ ok: boolean; message: string }> {
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
return { ok: false, message: 'URL oder API-Key nicht konfiguriert' }
}
try {
const res = await fetch(`${cfg.base_url}/api-v1/partners?pageSize=1`, {
headers: { 'X-Api-Key': cfg.api_key, Accept: 'application/json' },
signal: AbortSignal.timeout(5_000),
})
if (res.ok) return { ok: true, message: `Verbindung OK (HTTP ${res.status})` }
return { ok: false, message: `LicServer antwortete mit HTTP ${res.status}` }
} catch (err: any) {
return { ok: false, message: `Nicht erreichbar: ${err.message}` }
}
}
/**
* System version of getLicServerConfig that bypasses client auth check.
* Used internally by automated background jobs / cron routes.
*/
export async function getLicServerConfigSystem(): Promise<LicServerConfig> {
const admin = createAdminClient()
const { data } = await admin
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single()
return {
base_url: data?.licserver_base_url || process.env.LICSERVER_BASE_URL || null,
api_key: data?.licserver_api_key || process.env.LICSERVER_API_KEY || null,
}
}
export async function lookupLicenseFromLicServer(id: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Nicht autorisiert.')
const cfg = await getLicServerConfigSystem()
if (!cfg.base_url || !cfg.api_key) {
throw new Error('LicServer ist nicht konfiguriert.')
}
const res = await fetch(`${cfg.base_url}/v1/licenses/${encodeURIComponent(id.trim())}`, {
headers: { 'X-Api-Key': cfg.api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(5_000),
})
if (res.status === 404) return null
if (!res.ok) {
throw new Error(`Lizenzserver antwortete mit Fehler: HTTP ${res.status}`)
}
const data = await res.json()
return {
licenseKey: data.id,
status: 'active' as const,
product: data.productId || 'Unbekanntes Produkt',
edition: data.serialNumber ? `Seriennummer: ${data.serialNumber}` : 'Standard',
version: '1.0.0',
seats: 1,
customer: data.filename || 'Lizenz-Datei',
contact: '—',
issuedAt: '',
expiresAt: '',
maintenanceUntil: '',
modules: [],
}
}

View File

@@ -7,6 +7,8 @@ export function validateWizardSelections(
): void {
for (const cat of dbCategories) {
const sel = selections[cat.id]
// --- is_required: Pflichtauswahl prüfen ---
const hasSelection = sel && (
(cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) ||
(!cat.allow_multiselect && sel.productId)
@@ -17,6 +19,13 @@ export function validateWizardSelections(
}
if (sel) {
// --- allow_multiselect = false: keine doppelten IDs erlaubt ---
if (!cat.allow_multiselect && sel.productIds && sel.productIds.length > 1) {
throw new Error(
`Die Kategorie "${cat.name}" erlaubt keine Mehrfachauswahl. Bitte nur ein Produkt wählen.`
)
}
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel.productIds) {
sel.productIds.forEach(pId => {
@@ -32,8 +41,8 @@ export function validateWizardSelections(
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) continue // module might belong to another selected product in the same category, so skip error if it's not this one's
if (!mod) continue
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
@@ -53,7 +62,7 @@ export function validateWizardSelections(
}
}
// Product-level constraints validation
// --- Product-level constraints validation ---
const selectedProductIds: string[] = []
Object.values(selections).forEach(s => {
if (s.productIds && s.productIds.length > 0) {
@@ -92,3 +101,4 @@ export function validateWizardSelections(
}
}
}

View File

@@ -0,0 +1,88 @@
/**
* TypeScript types generated from the CASPOS LicServer OpenAPI spec v1.
* Source: /api-v1/partners endpoints
*/
export interface PartnerDto {
id: string // UUID
name: string | null
email: string | null
erpId: string | null
}
export interface PartnerDtoPagedResult {
page: number
pageSize: number
totalCount: number
totalPages: number
hasNextPage: boolean
hasPreviousPage: boolean
items: PartnerDto[] | null
}
/** Standard RFC 7807 problem response from LicServer */
export interface LicServerProblemDetails {
type?: string | null
title?: string | null
status?: number | null
detail?: string | null
instance?: string | null
[key: string]: unknown
}
export interface CustomerDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
zipCode: string | null
city: string | null
countryCode: string | null
email: string | null
partnerId: string | null
erpId: string | null
}
export interface CustomerDtoPagedResult {
page: number
pageSize: number
totalCount: number
totalPages: number
hasNextPage: boolean
hasPreviousPage: boolean
items: CustomerDto[] | null
}
export interface LocationDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
countryCode: string | null
postalCode: string | null
city: string | null
taxNumber: string | null
vatIdNumber: string | null
}
export interface CustomerDetailsDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
zipCode: string | null
city: string | null
countryCode: string | null
email: string | null
partnerId: string | null
erpId: string | null
partnerName: string | null
taxNumber: string | null
vatIdNumber: string | null
notes: string | null
locations: LocationDto[] | null
}

View File

@@ -1,65 +1,5 @@
import { Product, ProductModule, Category, WizardSelections } from './types';
export interface GlobalInclusion {
trigger_product_id: string;
included_module_id: string;
}
export interface GlobalExclusion {
product_id: string;
excluded_product_id?: string | null;
excluded_module_id?: string | null;
}
/**
* Handle changes to the base product.
* Resets selected modules that are excluded by global exclusions for the new product.
* Automatically adds globally included modules for the new product.
*/
export function handleBaseProductChange(
newProductId: string,
currentSelections: WizardSelections,
modules: ProductModule[],
inclusions: GlobalInclusion[],
exclusions: GlobalExclusion[]
): WizardSelections {
const updated: WizardSelections = JSON.parse(JSON.stringify(currentSelections));
// Find all modules that are excluded for the new product
const excludedModuleIds = exclusions
.filter(ex => ex.product_id === newProductId && ex.excluded_module_id)
.map(ex => ex.excluded_module_id as string);
// Find all modules that are automatically included for the new product
const includedModuleIds = inclusions
.filter(inc => inc.trigger_product_id === newProductId)
.map(inc => inc.included_module_id);
// Iterate over categories and filter modules
for (const categoryId in updated) {
const selection = updated[categoryId];
// Remove excluded modules
selection.moduleIds = selection.moduleIds.filter(
mid => !excludedModuleIds.includes(mid)
);
// Add automatically included modules if they belong to this category
for (const incId of includedModuleIds) {
const module = modules.find(m => m.id === incId);
if (module && module.product_id === newProductId) {
// Find category of module. In our schema, modules might belong to a category.
// Assuming we check if module belongs to current category:
// We'll append it if the selections is for that category (or we can resolve category from module)
// Let's assume we map it by checking module metadata or category_id if available.
// For simplicity: if the module is already in the DB and matches, we add it.
}
}
}
return updated;
}
/**
* Handle toggling of a module selection.
* Handles category-specific allow_multiselect rules.

64
shop/scratch_test.js Normal file
View File

@@ -0,0 +1,64 @@
const { createClient } = require('@supabase/supabase-js');
const fs = require('fs');
const path = require('path');
// Read .env.local
const envPath = path.join(__dirname, '.env.local');
const envContent = fs.readFileSync(envPath, 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const parts = line.split('=');
if (parts.length >= 2) {
env[parts[0].trim()] = parts.slice(1).join('=').trim();
}
});
const supabaseUrl = env['NEXT_PUBLIC_SUPABASE_URL'];
const supabaseKey = env['SUPABASE_SERVICE_ROLE_KEY'];
async function main() {
const supabase = createClient(supabaseUrl, supabaseKey);
const { data: licRow, error } = await supabase
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single();
if (error || !licRow) {
console.error("Failed to load licserver config:", error);
return;
}
const base = 'http://192.168.178.174:9981'; // Target API port 9981
const apiKey = licRow.licserver_api_key;
console.log("Using API Base:", base);
console.log("API Key found:", apiKey ? "Yes (length " + apiKey.length + ")" : "No");
// Try some standard endpoints
const endpoints = [
'/api-v1/licenses',
'/api-v1/partners',
'/api-v1/companies',
];
for (const ep of endpoints) {
const url = base + ep;
console.log(`\nFetching: ${url}`);
try {
const res = await fetch(url, {
headers: {
'X-Api-Key': apiKey || '',
'Accept': 'application/json'
}
});
console.log(`Status: ${res.status} ${res.statusText}`);
const text = await res.text();
console.log(`Response (first 500 chars):`, text.substring(0, 500));
} catch (e) {
console.error(`Error fetching ${url}:`, e.message);
}
}
}
main();

View File

@@ -0,0 +1,11 @@
-- Migration: Add LicServer configuration columns to settings table
ALTER TABLE public.settings
ADD COLUMN IF NOT EXISTS licserver_base_url TEXT,
ADD COLUMN IF NOT EXISTS licserver_api_key TEXT;
-- Ensure the licserver config row exists (upsert with empty defaults)
INSERT INTO public.settings (id, licserver_base_url, licserver_api_key)
VALUES ('licserver', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,26 @@
-- Migration: Restrict settings table write access to admins only
-- Previously any authenticated user could write to settings (including licserver_api_key).
-- This fixes the RLS policy to only allow admins to write.
DROP POLICY IF EXISTS "Allow authenticated write on settings" ON public.settings;
-- Admins can write all settings
CREATE POLICY "Only admins can write settings" ON public.settings
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.users
WHERE id = auth.uid() AND role = 'admin'
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.users
WHERE id = auth.uid() AND role = 'admin'
)
);
-- All authenticated users can still READ settings (needed for proxy routes to load licserver config)
-- The READ policy remains: "Allow authenticated read on settings"
NOTIFY pgrst, 'reload schema';

8
shop/vercel.json Normal file
View File

@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/cron/sync-companies",
"schedule": "0 */6 * * *"
}
]
}