Compare commits
13 Commits
7119383139
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f647409f | ||
|
|
5d3eed874c | ||
|
|
3a090b7664 | ||
|
|
e2d92a031f | ||
|
|
6b9023c871 | ||
|
|
f783a98392 | ||
|
|
d5593900fb | ||
|
|
92eb7454dc | ||
|
|
cb4787e595 | ||
|
|
3fb80e6404 | ||
|
|
a318a5e225 | ||
|
|
1b49348577 | ||
|
|
fb61f5f396 |
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
@@ -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
227
.brain/licserver-api.md
Normal 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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -21,47 +21,57 @@ export default function AdminSettings() {
|
||||
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 [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);
|
||||
setLoading(false);
|
||||
|
||||
// Load current LicServer config from DB
|
||||
const supabaseClient = createClient();
|
||||
const { data: licRow } = await supabaseClient
|
||||
.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 || '');
|
||||
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);
|
||||
}
|
||||
}
|
||||
checkAccess();
|
||||
@@ -80,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');
|
||||
@@ -174,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>
|
||||
)}
|
||||
@@ -195,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"
|
||||
>
|
||||
@@ -225,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"
|
||||
@@ -241,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"
|
||||
@@ -265,29 +274,26 @@ export default function AdminSettings() {
|
||||
LicServer Konfiguration
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||
Verbindungseinstellungen zum CASPOS Lizenzserver (internes Container-Netz).
|
||||
Der API-Key wird verschlüsselt in der Datenbank gespeichert und nie an den Browser übertragen.
|
||||
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'
|
||||
}`}>
|
||||
<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'
|
||||
}`}>
|
||||
<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" />}
|
||||
@@ -308,7 +314,7 @@ export default function AdminSettings() {
|
||||
placeholder="http://192.168.178.174:9980"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400">Interne Container-URL ohne trailing slash</p>
|
||||
<p className="text-[10px] text-slate-400">URL des CASPOS Lizenzservers</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
@@ -334,51 +340,51 @@ export default function AdminSettings() {
|
||||
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400">Wird als X-Api-Key Header an den LicServer gesendet</p>
|
||||
|
||||
</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>
|
||||
{/* 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
25
shop/app/api/cron/sync-companies/route.ts
Normal file
25
shop/app/api/cron/sync-companies/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
25
shop/app/api/cron/sync-customers/route.ts
Normal file
25
shop/app/api/cron/sync-customers/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
85
shop/app/api/licserver/partners/[id]/customers/route.ts
Normal file
85
shop/app/api/licserver/partners/[id]/customers/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} 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> = {
|
||||
@@ -136,11 +137,14 @@ function fmt(dateStr: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── MOCK LOOKUP (replace with real LicServer API call) ──────────────────────
|
||||
// ─── LOOKUP (tries real LicServer API call, falls back to MOCK in dev) ──────────────────────
|
||||
async function lookupLicense(key: string): Promise<LicenseInfo | null> {
|
||||
// TODO: replace with → fetch(`/api/license-lookup?key=${encodeURIComponent(key)}`)
|
||||
await new Promise(r => setTimeout(r, 800)) // simulate network latency
|
||||
return MOCK_LICENSES[key.trim().toUpperCase()] ?? 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
|
||||
export interface LicServerConfig {
|
||||
base_url: string | null
|
||||
@@ -90,3 +91,61 @@ export async function testLicServerConnection(): Promise<{ ok: boolean; message:
|
||||
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: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,60 @@ export interface LicServerProblemDetails {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
64
shop/scratch_test.js
Normal file
64
shop/scratch_test.js
Normal 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();
|
||||
8
shop/vercel.json
Normal file
8
shop/vercel.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/sync-companies",
|
||||
"schedule": "0 */6 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user