Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
7
apps/web/next-env.d.ts
vendored
Normal file
7
apps/web/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
14
apps/web/next.config.mjs
Normal file
14
apps/web/next.config.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
export default {
|
||||
poweredByHeader: false,
|
||||
async redirects() { return [{ source: '/admin/connectors', destination: '/einstellungen/verbindungen', permanent: true }]; },
|
||||
async rewrites() { return [{ source: '/api/:path*', destination: `${process.env.KC_API_URL ?? 'http://127.0.0.1:4100'}/v1/:path*` }]; },
|
||||
async headers() {
|
||||
return [{ source: '/:path*', headers: [
|
||||
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'same-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
] }];
|
||||
},
|
||||
};
|
||||
25
apps/web/package.json
Normal file
25
apps/web/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@kc/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4101 -H 127.0.0.1",
|
||||
"build": "next build",
|
||||
"start": "next start -p 4101 -H 127.0.0.1",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "echo 'keine Web-Tests'"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^16.3.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
21
apps/web/src/app/(app)/admin/audit/page.tsx
Normal file
21
apps/web/src/app/(app)/admin/audit/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Alert, Empty, fmt } from '@/components/ui';
|
||||
|
||||
interface Ev { id: number; ts: string; actorType: string; actorId: string | null; action: string; resourceType: string | null; resourceId: string | null; result: string; correlationId: string | null; ip: string | null }
|
||||
export default function Audit() {
|
||||
const [list, setList] = useState<Ev[] | null>(null); const [action, setAction] = useState(''); const [err, setErr] = useState(''); const [chain, setChain] = useState<{ checked: number; brokenAt: number | null } | null>(null);
|
||||
const load = useCallback(() => api<Ev[]>('GET', `/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [action]);
|
||||
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
|
||||
return (<>
|
||||
<div className="row between"><h1>Audit-Protokoll</h1><button className="btn" onClick={() => api<{ checked: number; brokenAt: number | null }>('GET', '/admin/audit/verify').then(setChain).catch((e) => setErr(errMsg(e)))}>Integrität prüfen</button></div>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{chain && (chain.brokenAt === null ? <Alert kind="ok">Hash-Kette intakt ({chain.checked} Einträge geprüft).</Alert> : <Alert kind="err">Manipulation erkannt: Kette ab Eintrag {chain.brokenAt} fehlerhaft.</Alert>)}
|
||||
<div className="card"><div className="field"><label htmlFor="a">Aktion beginnt mit</label><input id="a" value={action} onChange={(e) => setAction(e.target.value)} placeholder="z. B. auth. oder customer." /></div>
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Einträge" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Zeit</th><th>Aktion</th><th>Akteur</th><th>Objekt</th><th>Ergebnis</th></tr></thead><tbody>
|
||||
{list.map((e) => <tr key={e.id}><td>{fmt(e.ts)}</td><td className="mono">{e.action}</td><td className="mono small">{e.actorType}{e.actorId ? ` ${e.actorId.slice(0, 8)}` : ''}</td><td className="mono small">{e.resourceType ?? ''} {e.resourceId?.slice(0, 8) ?? ''}</td>
|
||||
<td><span className={`badge ${e.result === 'success' ? 'ok' : 'err'}`}><span aria-hidden="true">{e.result === 'success' ? '✓' : '✕'}</span>{e.result === 'success' ? 'Erfolg' : e.result === 'denied' ? 'Abgelehnt' : 'Fehler'}</span></td></tr>)}</tbody></table></div>}
|
||||
</div></>);
|
||||
}
|
||||
23
apps/web/src/app/(app)/admin/auftraege/page.tsx
Normal file
23
apps/web/src/app/(app)/admin/auftraege/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, JOB_LABEL, JobStatus, fmt } from '@/components/ui';
|
||||
|
||||
interface J { id: string; type: string; status: string; attempts: number; maxAttempts: number; error: string | null; createdAt: string; correlationId: string | null }
|
||||
const TYPE: Record<string, string> = { 'connector.sync': 'Abgleich', 'connector.execute': 'Aktion beim Anbieter', 'discord.notify': 'Discord-Benachrichtigung' };
|
||||
export default function Auftraege() {
|
||||
const { can } = useSession(); const w = can('resources.write');
|
||||
const [list, setList] = useState<J[] | null>(null); const [status, setStatus] = useState(''); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
|
||||
const load = useCallback(() => api<J[]>('GET', `/admin/jobs${status ? `?status=${status}` : ''}`).then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [status]);
|
||||
useEffect(() => { void load(); const t = setInterval(() => void load(), 5000); return () => clearInterval(t); }, [load]);
|
||||
async function retry(j: J) { try { await api('POST', `/admin/jobs/${j.id}/retry`); setMsg({ k: 'ok', t: 'Auftrag wird erneut ausgeführt.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
return (<>
|
||||
<h1>Aufträge</h1>{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
<div className="card"><Field id="st" label="Status"><select id="st" value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option>{Object.entries(JOB_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Aufträge" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Art</th><th>Status</th><th>Versuche</th><th>Angelegt</th><th><span className="sr">Aktion</span></th></tr></thead><tbody>
|
||||
{list.map((j) => <tr key={j.id}><td>{TYPE[j.type] ?? j.type}<div className="small muted mono">{j.correlationId?.slice(0, 8)}</div></td><td><JobStatus value={j.status} />{j.error && <div className="small muted">{j.error}</div>}</td><td>{j.attempts}/{j.maxAttempts}</td><td>{fmt(j.createdAt)}</td>
|
||||
<td>{w && (j.status === 'needs_review' || j.status === 'failed') && <button className="btn small" onClick={() => retry(j)}>Erneut ausführen</button>}</td></tr>)}</tbody></table></div>}
|
||||
</div></>);
|
||||
}
|
||||
45
apps/web/src/app/(app)/admin/benutzer/page.tsx
Normal file
45
apps/web/src/app/(app)/admin/benutzer/page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, ROLE_LABEL, Status } from '@/components/ui';
|
||||
|
||||
interface U { id: string; email: string; name: string; kind: 'customer' | 'staff'; staffRole: string | null; status: string; mfa: boolean }
|
||||
export default function Benutzer() {
|
||||
const { me, can } = useSession();
|
||||
const [list, setList] = useState<U[] | null>(null); const [q, setQ] = useState(''); const [kind, setKind] = useState(''); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [open, setOpen] = useState(false); const [resetFor, setResetFor] = useState<U | null>(null);
|
||||
const load = useCallback(() => api<U[]>('GET', `/admin/users?${new URLSearchParams({ ...(q ? { q } : {}), ...(kind ? { kind } : {}) })}`).then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [q, kind]);
|
||||
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
|
||||
const priv = can('users.write_privileged');
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget);
|
||||
try { const r = await api<{ mail: string; inviteLink?: string }>('POST', '/admin/users', { name: f.get('name'), email: f.get('email'), staffRole: f.get('role') });
|
||||
setMsg(r.mail === 'sent' ? { k: 'ok', t: 'Einladung wurde versendet.' } : { k: 'warn', t: `E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): ${r.inviteLink}` }); setOpen(false); void load(); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function toggle(u: U) {
|
||||
try { await api('PATCH', `/admin/users/${u.id}`, { status: u.status === 'active' ? 'disabled' : 'active' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function resetMfa(u: U) {
|
||||
try { await api('POST', `/admin/users/${u.id}/mfa-reset`); setResetFor(null); setMsg({ k: 'ok', t: `2FA von ${u.name} wurde zurückgesetzt und alle Sitzungen beendet. ${u.kind === 'staff' ? 'Beim nächsten Login muss die 2FA neu eingerichtet werden.' : 'Der Kunde kann sie unter „Mein Konto“ neu einrichten.'}` }); void load(); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
return (<>
|
||||
<div className="row between"><h1>Benutzer</h1>{can('users.write') && <button className="btn primary" aria-expanded={open} onClick={() => setOpen(!open)}>Mitarbeiter anlegen</button>}</div>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{open && <form className="card" onSubmit={create}><h2>Neuer Mitarbeiter</h2><div className="cols">
|
||||
<Field id="name" label="Name"><input id="name" name="name" required autoFocus /></Field><Field id="email" label="E-Mail"><input id="email" name="email" type="email" required /></Field>
|
||||
<Field id="role" label="Rolle"><select id="role" name="role"><option value="support">Support</option><option value="accounting">Buchhaltung</option>{priv && <><option value="admin">Administrator</option><option value="superadmin">Superadministrator</option></>}</select></Field></div>
|
||||
<p className="small muted">Mitarbeiter müssen bei der ersten Anmeldung 2FA einrichten.</p><button className="btn primary" type="submit">Einladung senden</button></form>}
|
||||
{resetFor && <div className="card" role="alertdialog" aria-labelledby="rt"><p id="rt"><strong>2FA von {resetFor.name} zurücksetzen?</strong> Alle Sitzungen des Benutzers werden beendet. Bitte nur nach Prüfung der Identität ausführen. Die Aktion wird protokolliert.</p>
|
||||
<div className="row"><button className="btn danger" onClick={() => resetMfa(resetFor)}>Ja, zurücksetzen</button><button className="btn" onClick={() => setResetFor(null)}>Abbrechen</button></div></div>}
|
||||
<div className="card"><div className="cols">
|
||||
<Field id="q" label="Suche"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} /></Field>
|
||||
<Field id="kind" label="Typ"><select id="kind" value={kind} onChange={(e) => setKind(e.target.value)}><option value="">Alle</option><option value="staff">Mitarbeiter</option><option value="customer">Kunden</option></select></Field></div>
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Benutzer gefunden" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>2FA</th><th>Status</th><th><span className="sr">Aktion</span></th></tr></thead><tbody>
|
||||
{list.map((u) => <tr key={u.id}><td>{u.name}</td><td>{u.email}</td><td>{u.staffRole ? ROLE_LABEL[u.staffRole] : 'Kunde'}</td><td>{u.mfa ? 'Ja' : 'Nein'}</td><td><Status value={u.status} /></td>
|
||||
<td><div className="row">{can('users.write') && u.id !== me?.user.id && u.status !== 'invited' && (u.staffRole !== 'admin' && u.staffRole !== 'superadmin' || priv) && <button className="btn small" onClick={() => toggle(u)}>{u.status === 'active' ? 'Sperren' : 'Entsperren'}</button>}
|
||||
{can('users.write') && u.id !== me?.user.id && u.mfa && (u.kind === 'customer' || priv) && <button className="btn small" onClick={() => setResetFor(u)}>2FA zurücksetzen</button>}</div></td></tr>)}</tbody></table></div>}
|
||||
</div></>);
|
||||
}
|
||||
11
apps/web/src/app/(app)/admin/domains/page.tsx
Normal file
11
apps/web/src/app/(app)/admin/domains/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { DomainRecords } from '@/components/DomainRecords';
|
||||
|
||||
export default function Aufstellung() {
|
||||
return (<>
|
||||
<div className="row between"><h1>Domain-Aufstellung</h1><Link className="btn" href="/admin/produkte/domains">Preisliste & Aufschlag</Link></div>
|
||||
<p className="muted">Je Domain: <strong>Einkauf</strong> (Preisliste von KCS, dort Bruttopreise inkl. USt; netto herausgerechnet) und Ihr <strong>errechneter Verkaufspreis</strong> brutto und netto. Die USt wird nicht doppelt aufgeschlagen. Damit sehen Sie, was Sie bei KCS bestellen müssen und was Sie berechnen.</p>
|
||||
<DomainRecords />
|
||||
</>);
|
||||
}
|
||||
81
apps/web/src/app/(app)/admin/kunden/[id]/page.tsx
Normal file
81
apps/web/src/app/(app)/admin/kunden/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { SecretField } from '@/components/SecretField';
|
||||
import { DomainRecords } from '@/components/DomainRecords';
|
||||
import { Alert, ContractStatusBadge, Empty, Field, fmt, OrderStatusBadge, ResState, ROLE_LABEL, Status } from '@/components/ui';
|
||||
|
||||
interface Org { id: string; name: string; customerNumber: string; customerType: 'private' | 'business'; status: string; billing: Record<string, string | null>; members: { id: string; email: string; name: string; status: string; role: string }[] }
|
||||
interface CRow { id: string; number: string; status: string; productName: string; termEnd: string | null; cancelEffectiveAt: string | null }
|
||||
interface ORow { id: string; number: string; status: string; createdAt: string; items: { snapshot: { name: string } }[] }
|
||||
interface RRow { id: string; name: string; state: string; validUntil: string | null; stale: boolean; canReveal?: boolean }
|
||||
export default function Kunde() {
|
||||
const { id } = useParams<{ id: string }>(); const { can } = useSession();
|
||||
const [contracts, setContracts] = useState<CRow[] | null>(null); const [orders, setOrders] = useState<ORow[] | null>(null); const [ress, setRess] = useState<RRow[] | null>(null);
|
||||
const [o, setO] = useState<Org | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState<string | null>(null); const [switchTo, setSwitchTo] = useState<'private' | 'business' | null>(null);
|
||||
const load = useCallback(() => api<Org>('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
|
||||
useEffect(() => { void load(); api<CRow[]>('GET', `/contracts?org=${id}`).then(setContracts).catch(() => setContracts([])); api<ORow[]>('GET', `/orders?org=${id}`).then(setOrders).catch(() => setOrders([])); api<RRow[]>('GET', `/resources?org=${id}`).then(setRess).catch(() => setRess([])); }, [load, id]);
|
||||
if (!o) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||
const w = can('customers.write');
|
||||
async function save(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget); const g = (k: string) => (f.get(k) as string | null) ?? '';
|
||||
const priv = o!.customerType === 'private';
|
||||
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { name: g('name'), billing: { street: g('street'), zip: g('zip'), city: g('city'), billingEmail: g('billingEmail'), phone: g('phone'), ...(priv ? {} : { company: g('company'), vatId: g('vatId') }) } })); setMsg({ k: 'ok', t: 'Gespeichert.' }); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function changeType(t: 'private' | 'business') {
|
||||
// Beim Wechsel zum Privatkunden werden Firma und USt-IdNr. entfernt (Bestätigung vorher).
|
||||
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { customerType: t, ...(t === 'private' ? { billing: { company: '', vatId: '' } } : { billing: { company: o!.name } }) })); setSwitchTo(null); setMsg({ k: 'ok', t: 'Kundenart geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function reinvite(uid: string, email: string) {
|
||||
try { const r = await api<{ mail: string; inviteLink?: string }>('POST', `/admin/users/${uid}/reinvite`); setMsg(r.mail === 'sent' ? { k: 'ok', t: `Die Einladung wurde an ${email} gesendet.` } : { k: 'warn', t: `E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): ${r.inviteLink}` }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function setStatus(status: string) {
|
||||
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { status })); setConfirm(null); setMsg({ k: 'ok', t: 'Status geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
return (<>
|
||||
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
|
||||
<div className="row between"><h1>{o.name}</h1><Status value={o.status} /></div>
|
||||
<p className="muted"><span className="mono">{o.customerNumber}</span> · <span className="badge info">{o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}</span></p>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
<form className="card" onSubmit={save}><h2>Stammdaten & Rechnungsanschrift</h2>
|
||||
<div className="cols"><Field id="name" label={o.customerType === 'business' ? 'Firmenname' : 'Name'}><input id="name" name="name" defaultValue={o.name} disabled={!w} required /></Field>
|
||||
{o.customerType === 'business' && <Field id="company" label="Firma (Rechnung)"><input id="company" name="company" defaultValue={o.billing.company ?? ''} disabled={!w} /></Field>}
|
||||
<Field id="street" label="Straße"><input id="street" name="street" defaultValue={o.billing.street ?? ''} disabled={!w} /></Field>
|
||||
<Field id="zip" label="PLZ"><input id="zip" name="zip" defaultValue={o.billing.zip ?? ''} disabled={!w} /></Field>
|
||||
<Field id="city" label="Ort"><input id="city" name="city" defaultValue={o.billing.city ?? ''} disabled={!w} /></Field>
|
||||
{o.customerType === 'business' && <Field id="vatId" label="USt-IdNr."><input id="vatId" name="vatId" defaultValue={o.billing.vatId ?? ''} disabled={!w} /></Field>}
|
||||
<Field id="billingEmail" label="Rechnungs-E-Mail"><input id="billingEmail" name="billingEmail" type="email" defaultValue={o.billing.billingEmail ?? ''} disabled={!w} /></Field>
|
||||
<Field id="phone" label="Telefon"><input id="phone" name="phone" defaultValue={o.billing.phone ?? ''} disabled={!w} /></Field></div>
|
||||
{w && <button className="btn primary" type="submit">Speichern</button>}</form>
|
||||
<div className="card"><div className="row between"><h2>Verträge</h2>{can('orders.write') && <Link className="btn small" href="/bestellen">Bestellung anlegen</Link>}</div>
|
||||
{contracts === null ? <p className="muted" role="status">Wird geladen …</p> : contracts.length === 0 ? <Empty title="Noch keine Verträge" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Produkt</th><th>Laufzeit bis</th><th>Status</th></tr></thead><tbody>
|
||||
{contracts.map((c) => <tr key={c.id}><td className="mono"><Link href={`/vertraege/${c.id}`}>{c.number}</Link></td><td>{c.productName}</td><td>{c.termEnd ? fmt(c.termEnd) : 'unbefristet'}{c.cancelEffectiveAt && <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Gekündigt zum {fmt(c.cancelEffectiveAt)}</span></div>}</td><td><ContractStatusBadge value={c.status} /></td></tr>)}</tbody></table></div>}</div>
|
||||
<div className="card"><h2>Ressourcen und Lizenzen</h2>
|
||||
{ress === null ? <p className="muted" role="status">Wird geladen …</p> : ress.length === 0 ? <Empty title="Keine Ressourcen zugeordnet" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Name</th><th>Status</th><th>Gültig bis</th><th>Lizenzschlüssel</th></tr></thead><tbody>
|
||||
{ress.map((r) => <tr key={r.id}><td><Link href={`/ressourcen/${r.id}`}>{r.name}</Link>{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</td><td><ResState value={r.state} /></td><td>{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</td><td>{r.canReveal ? <SecretField resourceId={r.id} compact /> : <span className="muted small">–</span>}</td></tr>)}</tbody></table></div>}</div>
|
||||
{can('domains.read') && <section aria-labelledby="h-dom"><h2 id="h-dom">Domains</h2><DomainRecords orgId={String(id)} /></section>}
|
||||
<div className="card"><h2>Bestellungen</h2>
|
||||
{orders === null ? <p className="muted" role="status">Wird geladen …</p> : orders.length === 0 ? <Empty title="Noch keine Bestellungen" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Positionen</th><th>Bestellt am</th><th>Status</th></tr></thead><tbody>
|
||||
{orders.map((x) => <tr key={x.id}><td className="mono"><Link href="/bestellungen">{x.number}</Link></td><td>{x.items.map((i) => i.snapshot.name).join(', ')}</td><td>{fmt(x.createdAt)}</td><td><OrderStatusBadge value={x.status} /></td></tr>)}</tbody></table></div>}</div>
|
||||
<div className="card"><h2>Benutzer</h2><div className="tablewrap"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th><span className="sr">Aktionen</span></th></tr></thead><tbody>
|
||||
{o.members.map((m) => <tr key={m.id}><td>{m.name}</td><td>{m.email}</td><td>{ROLE_LABEL[m.role]}</td><td><Status value={m.status} /></td><td>{w && m.status === 'invited' && <button className="btn small" onClick={() => reinvite(m.id, m.email)}>Einladung senden</button>}</td></tr>)}</tbody></table></div></div>
|
||||
{w && <div className="card"><h2>Kundenart</h2>
|
||||
{switchTo ? (<div role="alertdialog" aria-labelledby="st"><p id="st"><strong>Zu {switchTo === 'private' ? 'Privatkunde' : 'Geschäftskunde'} wechseln?</strong> {switchTo === 'private' ? 'Firma und USt-IdNr. werden entfernt.' : 'Als Firma wird der bisherige Name übernommen.'} Das kann Auswirkungen auf Rechnungen und Steuer haben. Die Änderung wird protokolliert.</p>
|
||||
<div className="row"><button className="btn primary" onClick={() => changeType(switchTo)}>Ja, ändern</button><button className="btn" onClick={() => setSwitchTo(null)}>Abbrechen</button></div></div>)
|
||||
: <button className="btn" onClick={() => setSwitchTo(o.customerType === 'business' ? 'private' : 'business')}>Zu {o.customerType === 'business' ? 'Privatkunde' : 'Geschäftskunde'} ändern</button>}</div>}
|
||||
{w && <div className="card"><h2>Gefährliche Aktionen</h2>
|
||||
{confirm ? (<div role="alertdialog" aria-labelledby="ct"><p id="ct"><strong>{confirm === 'suspended' ? 'Kunde sperren?' : confirm === 'closed' ? 'Kunde beenden?' : 'Kunde entsperren?'}</strong> Die Aktion wird protokolliert.</p>
|
||||
<div className="row"><button className="btn danger" onClick={() => setStatus(confirm)}>Ja, ausführen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>)
|
||||
: <div className="row">{o.status !== 'active' && o.status !== 'closed' && <button className="btn" onClick={() => setConfirm('active')}>Entsperren</button>}
|
||||
{o.status === 'active' && <button className="btn danger" onClick={() => setConfirm('suspended')}>Sperren</button>}
|
||||
{o.status !== 'closed' && <button className="btn danger" onClick={() => setConfirm('closed')}>Beenden</button>}</div>}
|
||||
<p className="small muted" style={{ marginTop: 12 }}>Hinweis: Sperren wirkt aktuell nur auf den Kundenstatus. Provider-Ressourcen werden erst mit den Connectoren gesperrt.</p></div>}
|
||||
</>);
|
||||
}
|
||||
53
apps/web/src/app/(app)/admin/kunden/page.tsx
Normal file
53
apps/web/src/app/(app)/admin/kunden/page.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, Status } from '@/components/ui';
|
||||
|
||||
interface Cust { id: string; customerNumber: string; name: string; customerType: 'private' | 'business'; status: string; city: string | null; billingEmail: string | null; members: number }
|
||||
export default function Kunden() {
|
||||
const { can } = useSession();
|
||||
const [list, setList] = useState<Cust[] | null>(null); const [q, setQ] = useState(''); const [err, setErr] = useState(''); const [open, setOpen] = useState(false); const [ctype, setCtype] = useState<'private' | 'business'>('business'); const [typeFilter, setTypeFilter] = useState('');
|
||||
const [created, setCreated] = useState<{ id: string; customerNumber: string; mail: string; inviteLink?: string } | null>(null);
|
||||
const load = useCallback(() => api<Cust[]>('GET', `/admin/customers?${new URLSearchParams({ ...(q ? { q } : {}), ...(typeFilter ? { type: typeFilter } : {}) })}`).then((l) => { setList(l); setErr(''); }).catch((e) => setErr(errMsg(e))), [q, typeFilter]);
|
||||
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
|
||||
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); const f = new FormData(e.currentTarget); const s = (k: string) => (f.get(k) as string) || undefined;
|
||||
try {
|
||||
const priv = ctype === 'private';
|
||||
const name = priv ? `${f.get('firstName')} ${f.get('lastName')}`.trim() : (f.get('name') as string);
|
||||
const r = await api<NonNullable<typeof created>>('POST', '/admin/customers', {
|
||||
type: ctype, name, owner: priv ? { email: f.get('ownerEmail') } : { name: f.get('ownerName'), email: f.get('ownerEmail') },
|
||||
billing: { street: s('street'), zip: s('zip'), city: s('city'), country: s('country') ?? 'DE', phone: s('phone'), ...(priv ? {} : { vatId: s('vatId') }) } });
|
||||
setCreated(r); setOpen(false); void load();
|
||||
} catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
return (<>
|
||||
<div className="row between"><h1>Kunden</h1>{can('customers.write') && <div className="row"><Link className="btn" href="/admin/kunden/uebernahme">Aus Verbindung übernehmen</Link><button className="btn primary" onClick={() => { setOpen(!open); setCreated(null); }} aria-expanded={open}>Kunde anlegen</button></div>}</div>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{created && <Alert kind={created.mail === 'sent' ? 'ok' : 'warn'}>Kunde {created.customerNumber} angelegt. {created.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : <>E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): <code>{created.inviteLink}</code></>}</Alert>}
|
||||
{open && <form className="card" onSubmit={create}><h2>Neuer Kunde</h2>
|
||||
<fieldset style={{ border: 0, padding: 0, margin: '0 0 16px' }}><legend style={{ fontWeight: 600, marginBottom: 8 }}>Kundenart</legend>
|
||||
<div className="row">{([['business', 'Geschäftskunde'], ['private', 'Privatkunde']] as const).map(([v, l]) => <label key={v} className="row"><input type="radio" name="ctype" style={{ width: 20, minHeight: 20 }} checked={ctype === v} onChange={() => setCtype(v)} />{l}</label>)}</div></fieldset>
|
||||
{ctype === 'business' ? (<>
|
||||
<div className="cols"><Field id="name" label="Firmenname"><input id="name" name="name" required autoFocus /></Field><Field id="vatId" label="USt-IdNr." hint="Optional, z. B. DE123456789"><input id="vatId" name="vatId" /></Field></div>
|
||||
<h3>Ansprechpartner (erhält die Einladung)</h3>
|
||||
<div className="cols"><Field id="ownerName" label="Name"><input id="ownerName" name="ownerName" required /></Field><Field id="ownerEmail" label="E-Mail"><input id="ownerEmail" name="ownerEmail" type="email" required /></Field></div>
|
||||
</>) : (<>
|
||||
<div className="cols"><Field id="firstName" label="Vorname"><input id="firstName" name="firstName" required autoFocus /></Field><Field id="lastName" label="Nachname"><input id="lastName" name="lastName" required /></Field>
|
||||
<Field id="ownerEmail" label="E-Mail (erhält die Einladung)"><input id="ownerEmail" name="ownerEmail" type="email" required /></Field></div>
|
||||
</>)}
|
||||
<h3>Rechnungsanschrift</h3>
|
||||
<div className="cols"><Field id="street" label="Straße"><input id="street" name="street" /></Field><Field id="zip" label="PLZ"><input id="zip" name="zip" /></Field><Field id="city" label="Ort"><input id="city" name="city" /></Field>
|
||||
<Field id="country" label="Land (ISO)"><input id="country" name="country" defaultValue="DE" maxLength={2} /></Field><Field id="phone" label="Telefon"><input id="phone" name="phone" /></Field></div>
|
||||
<div className="row"><button className="btn primary" type="submit">Kunde anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
|
||||
<div className="card">
|
||||
<div className="cols"><Field id="q" label="Suche"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, Kundennummer oder E-Mail" /></Field>
|
||||
<Field id="tf" label="Kundenart"><select id="tf" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}><option value="">Alle</option><option value="business">Geschäftskunden</option><option value="private">Privatkunden</option></select></Field></div>
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title={q ? 'Keine Treffer' : 'Noch keine Kunden'}>{!q && 'Legen Sie den ersten Kunden über „Kunde anlegen“ an.'}</Empty> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Name</th><th>Art</th><th>Ort</th><th>Benutzer</th><th>Status</th></tr></thead><tbody>
|
||||
{list.map((c) => <tr key={c.id}><td className="mono">{c.customerNumber}</td><td><Link href={`/admin/kunden/${c.id}`}>{c.name}</Link></td><td>{c.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}</td><td>{c.city ?? '–'}</td><td>{c.members}</td><td><Status value={c.status} /></td></tr>)}</tbody></table></div>}
|
||||
</div></>);
|
||||
}
|
||||
70
apps/web/src/app/(app)/admin/kunden/uebernahme/page.tsx
Normal file
70
apps/web/src/app/(app)/admin/kunden/uebernahme/page.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, fmt } from '@/components/ui';
|
||||
|
||||
interface Cust { externalRef: string; displayName: string; company: string | null; firstName: string | null; lastName: string | null; email: string | null; planName: string | null; state: 'active' | 'suspended'; createdAt: string | null;
|
||||
imported: { orgId: string; customerNumber: string } | null; resourceKnown: boolean; resourceOrgId: string | null; suggestedProducts: { id: string; sku: string; name: string; status: string }[]; possibleOrgs: { id: string; customerNumber: string; name: string }[] }
|
||||
interface Conn { id: string; name: string; connector: string; capabilities: string[]; health: string }
|
||||
interface Row { sel: boolean; type: 'private' | 'business'; productId: string; start: string; link: string }
|
||||
interface Result { externalRef: string; status: 'created' | 'linked' | 'skipped'; reason?: string; orgId?: string; customerNumber?: string; contractNumber?: string }
|
||||
|
||||
export default function KundenUebernahme() {
|
||||
const { can } = useSession();
|
||||
const [conns, setConns] = useState<Conn[]>([]); const [connId, setConnId] = useState(''); const [list, setList] = useState<Cust[] | null>(null); const [err, setErr] = useState('');
|
||||
const [rows, setRows] = useState<Record<string, Row>>({}); const [q, setQ] = useState(''); const [busy, setBusy] = useState(false); const [results, setResults] = useState<Result[] | null>(null);
|
||||
useEffect(() => { api<Conn[]>('GET', '/admin/connectors').then((l) => { const c = l.filter((x) => x.capabilities.includes('customers.list')); setConns(c); setConnId(c[0]?.id ?? ''); if (!c.length) setList([]); }).catch((e) => setErr(errMsg(e))); }, []);
|
||||
const load = useCallback(() => {
|
||||
if (!connId) return; setList(null); setErr(''); setResults(null);
|
||||
api<Cust[]>('GET', `/admin/connectors/${connId}/customers`).then((l) => {
|
||||
setList(l);
|
||||
setRows(Object.fromEntries(l.map((c) => [c.externalRef, { sel: false, type: c.company ? 'business' : 'private', productId: c.suggestedProducts[0]?.id ?? '', start: (c.createdAt ?? new Date().toISOString()).slice(0, 10), link: '' } as Row])));
|
||||
}).catch((e) => { setErr(errMsg(e)); setList([]); });
|
||||
}, [connId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const set = (ref: string, p: Partial<Row>) => setRows((r) => ({ ...r, [ref]: { ...r[ref]!, ...p } }));
|
||||
const visible = useMemo(() => (list ?? []).filter((c) => !q || [c.displayName, c.email, c.planName].some((x) => x?.toLowerCase().includes(q.toLowerCase()))), [list, q]);
|
||||
const open = visible.filter((c) => !c.imported && (!c.resourceOrgId));
|
||||
const chosen = (list ?? []).filter((c) => rows[c.externalRef]?.sel);
|
||||
if (!can('customers.write')) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
||||
|
||||
async function run() {
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const items = chosen.map((c) => { const r = rows[c.externalRef]!; return { externalRef: c.externalRef, type: r.type, ...(r.link ? { linkToOrgId: r.link } : {}), ...(r.productId ? { contract: { productId: r.productId, startedAt: new Date(r.start).toISOString() } } : {}) }; });
|
||||
const r = await api<{ results: Result[] }>('POST', `/admin/connectors/${connId}/customers/import`, { items }); setResults(r.results); load();
|
||||
} catch (e) { setErr(errMsg(e)); } finally { setBusy(false); }
|
||||
}
|
||||
return (<>
|
||||
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
|
||||
<h1>Kunden übernehmen</h1>
|
||||
<p className="muted">Die Kunden werden live aus der Verbindung gelesen. Beim Anbieter wird nichts verändert, und es werden <strong>keine E-Mails</strong> an die Kunden versendet. Die Einladung zum Kundencenter senden Sie später gezielt.</p>
|
||||
{err && <Alert kind="err">{err} <button className="btn small" onClick={load}>Erneut versuchen</button></Alert>}
|
||||
<div className="card"><Field id="conn" label="Verbindung"><select id="conn" value={connId} onChange={(e) => setConnId(e.target.value)}>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}{c.health !== 'ok' ? ' (gestört)' : ''}</option>)}</select></Field></div>
|
||||
{results && <div className="card"><h2>Ergebnis</h2>
|
||||
<div className="tablewrap"><table><thead><tr><th>Konto</th><th>Ergebnis</th><th>Kunde</th><th>Vertrag</th></tr></thead><tbody>{results.map((r) => { const c = list?.find((x) => x.externalRef === r.externalRef); return <tr key={r.externalRef}><td>{c?.displayName ?? r.externalRef}</td>
|
||||
<td><span className={`badge ${r.status === 'skipped' ? 'warn' : 'ok'}`}><span aria-hidden="true">{r.status === 'skipped' ? '▲' : '✓'}</span>{r.status === 'created' ? 'Angelegt' : r.status === 'linked' ? 'Verknüpft' : 'Übersprungen'}</span>{r.reason && <div className="small muted">{r.reason}</div>}</td>
|
||||
<td>{r.orgId ? <Link href={`/admin/kunden/${r.orgId}`}>{r.customerNumber}</Link> : '–'}</td><td className="mono">{r.contractNumber ?? '–'}</td></tr>; })}</tbody></table></div>
|
||||
<p className="small muted" style={{ marginTop: 12 }}>Die Inhaber wurden als „eingeladen“ angelegt. Zum Zugang senden Sie die Einladung auf der Kundenseite unter „Benutzer“.</p></div>}
|
||||
{conns.length === 0 && list !== null && <div className="card"><Empty title="Keine geeignete Verbindung">Legen Sie unter Einstellungen → Verbindungen eine Verbindung an, die Kunden bereitstellt (z. B. KeyHelp).</Empty></div>}
|
||||
{list === null ? <p className="muted" role="status">Kunden werden beim Anbieter gelesen …</p> : list.length > 0 && (
|
||||
<div className="card"><div className="row between"><Field id="q" label="Suche"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, E-Mail oder Tarif" /></Field>
|
||||
<div className="row"><button className="btn small" onClick={() => setRows((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, { ...v, sel: open.some((c) => c.externalRef === k) ? true : v.sel }])))}>Alle noch nicht übernommenen wählen</button><button className="btn small" onClick={() => setRows((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, { ...v, sel: false }])))}>Auswahl aufheben</button></div></div>
|
||||
<div className="tablewrap"><table><thead><tr><th><span className="sr">Auswahl</span></th><th>Konto</th><th>Tarif</th><th>Art</th><th>Vertrag (Bestand)</th><th>Verknüpfen mit</th></tr></thead><tbody>
|
||||
{visible.map((c) => { const r = rows[c.externalRef]; if (!r) return null; const done = !!c.imported || !!c.resourceOrgId; return (<tr key={c.externalRef}>
|
||||
<td><input type="checkbox" style={{ width: 20, minHeight: 20 }} aria-label={`${c.displayName} auswählen`} checked={r.sel} disabled={done} onChange={(e) => set(c.externalRef, { sel: e.target.checked })} /></td>
|
||||
<td>{c.displayName}<div className="small muted">{c.email ?? 'keine E-Mail'} · seit {c.createdAt ? fmt(c.createdAt).split(',')[0] : '–'}{c.state === 'suspended' && <> · <span className="badge lock"><span aria-hidden="true">⊘</span>gesperrt</span></>}</div>
|
||||
{c.imported && <div className="small"><span className="badge ok"><span aria-hidden="true">✓</span>Übernommen</span> <Link href={`/admin/kunden/${c.imported.orgId}`}>{c.imported.customerNumber}</Link></div>}
|
||||
{!c.imported && c.resourceOrgId && <div className="small"><span className="badge info"><span aria-hidden="true">○</span>Konto bereits zugeordnet</span></div>}</td>
|
||||
<td>{c.planName ?? '–'}</td>
|
||||
<td><select aria-label="Kundenart" value={r.type} disabled={done} onChange={(e) => set(c.externalRef, { type: e.target.value as Row['type'] })} style={{ minHeight: 36, padding: '4px 8px' }}><option value="business">Geschäftskunde</option><option value="private">Privatkunde</option></select></td>
|
||||
<td><select aria-label="Produkt für den Bestandsvertrag" value={r.productId} disabled={done} onChange={(e) => set(c.externalRef, { productId: e.target.value })} style={{ minHeight: 36, padding: '4px 8px', maxWidth: 220 }}><option value="">Keinen Vertrag anlegen</option>{c.suggestedProducts.map((p) => <option key={p.id} value={p.id}>{p.name}{p.status !== 'active' ? ' (Entwurf)' : ''}</option>)}</select>
|
||||
{r.productId && <div style={{ marginTop: 6 }}><label className="small muted" htmlFor={`st-${c.externalRef}`}>Vertragsbeginn</label><input id={`st-${c.externalRef}`} type="date" value={r.start} onChange={(e) => set(c.externalRef, { start: e.target.value })} style={{ minHeight: 36, padding: '4px 8px' }} /></div>}
|
||||
{c.suggestedProducts.length === 0 && <div className="small muted">Kein Produkt zum Tarif vorhanden (erst Tarif als Produkt übernehmen).</div>}</td>
|
||||
<td>{c.possibleOrgs.length > 0 ? <select aria-label="Mit vorhandenem Kunden verknüpfen" value={r.link} disabled={done} onChange={(e) => set(c.externalRef, { link: e.target.value })} style={{ minHeight: 36, padding: '4px 8px' }}><option value="">Neuen Kunden anlegen</option>{c.possibleOrgs.map((o) => <option key={o.id} value={o.id}>{o.customerNumber} · {o.name}</option>)}</select> : <span className="muted small">Neuer Kunde</span>}</td></tr>); })}</tbody></table></div>
|
||||
<div className="row" style={{ marginTop: 16 }}><button className="btn primary" disabled={busy || chosen.length === 0} onClick={run}>{busy ? 'Bitte warten …' : `${chosen.length} Kunde(n) übernehmen`}</button>
|
||||
<span className="small muted">Bestandsverträge übernehmen Preis und Bedingungen des gewählten Produkts. Abrechnung folgt mit dem Rechnungsmodul.</span></div></div>)}
|
||||
</>);
|
||||
}
|
||||
50
apps/web/src/app/(app)/admin/produkte/[id]/page.tsx
Normal file
50
apps/web/src/app/(app)/admin/produkte/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, eur, Field, fmt, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
||||
import type { Prod } from '../page';
|
||||
|
||||
type D = Prod & { versions: NonNullable<Prod['current']>[] };
|
||||
export default function Produkt() {
|
||||
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const w = can('products.write');
|
||||
const [d, setD] = useState<D | null>(null); const [tax, setTax] = useState<{ id: string; name: string; rateBp: number }[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [open, setOpen] = useState(false);
|
||||
const load = useCallback(() => api<D>('GET', `/admin/products/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
|
||||
useEffect(() => { void load(); api<typeof tax>('GET', '/admin/tax-rates').then(setTax).catch(() => undefined); }, [load]);
|
||||
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||
const cur = d.current!;
|
||||
async function patch(b: object, ok: string) { try { await api('PATCH', `/admin/products/${id}`, b); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
async function newVersion(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget); const once = cur.billingInterval === 'once';
|
||||
try { await api('POST', `/admin/products/${id}/versions`, { name: f.get('name'), taxRateId: f.get('tax'), priceBasis: cur.priceBasis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(f.get('recurring') || 0) * 100), billingInterval: cur.billingInterval,
|
||||
termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning });
|
||||
setOpen(false); setMsg({ k: 'ok', t: 'Neue Version angelegt. Bestehende Verträge behalten ihren Preis.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
return (<>
|
||||
<p><Link href="/admin/produkte">← Alle Produkte</Link></p>
|
||||
<div className="row between"><h1>{cur.name}</h1><Status value={d.status === 'active' ? 'active' : d.status === 'draft' ? 'invited' : 'closed'} /></div><p className="muted mono">{d.sku}</p>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
<div className="card"><h2>Aktuelle Konditionen (Version {cur.version})</h2>
|
||||
<p>{cur.setupCents > 0 && <>Einrichtung {eur(cur.setupCents)} {cur.priceBasis === 'gross' ? 'brutto' : 'netto'}<br /></>}{cur.billingInterval === 'once' ? 'Einmalpreis' : `${INTERVAL[cur.billingInterval]} ${eur(cur.recurringCents)} ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}`} · Steuer {cur.taxBp / 100} %</p>
|
||||
<p className="small muted">{cur.termMonths > 0 ? `Mindestlaufzeit ${cur.termMonths} Monate` : 'Keine Mindestlaufzeit'}{cur.renewal === 'auto' ? `, Verlängerung um ${cur.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {cur.noticeDays} Tage · Bereitstellung: {d.connectorName ?? 'manuell'}{Object.keys(d.provisioning).length ? ` (${JSON.stringify(d.provisioning)})` : ''}</p>
|
||||
{w && <div className="row">{d.status !== 'active' && <button className="btn primary" onClick={() => patch({ status: 'active' }, 'Produkt aktiviert.')}>Aktivieren</button>}
|
||||
{d.status === 'active' && <button className="btn" onClick={() => patch({ status: 'retired' }, 'Produkt ausgelaufen. Es kann nicht mehr bestellt werden.')}>Auslaufen lassen</button>}
|
||||
<button className="btn" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Version (Preis ändern)</button></div>}</div>
|
||||
{open && <form className="card" onSubmit={newVersion}><h2>Neue Version</h2><p className="small muted">Preisänderungen gelten nur für neue Bestellungen. Laufzeit und Bereitstellung bleiben unverändert.</p>
|
||||
<div className="cols"><Field id="name" label="Bezeichnung"><input id="name" name="name" defaultValue={cur.name} required /></Field>
|
||||
<Field id="setup" label={`Einrichtung (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'})`}><input id="setup" name="setup" type="number" min={0} step="0.01" defaultValue={cur.setupCents / 100} /></Field>
|
||||
{cur.billingInterval !== 'once' && <Field id="recurring" label={`Preis (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" defaultValue={cur.recurringCents / 100} /></Field>}
|
||||
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === cur.taxBp)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
||||
<Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={cur.noticeDays} /></Field></div>
|
||||
<div className="row"><button className="btn primary" type="submit">Version speichern</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
|
||||
<div className="card"><h2>Verkaufsregeln</h2><div className="row">
|
||||
<label className="row"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.orderableByCustomer} onChange={(e) => patch({ orderableByCustomer: e.target.checked }, 'Gespeichert.')} />Von Kunden selbst bestellbar</label>
|
||||
<label className="row"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.requiresApproval} onChange={(e) => patch({ requiresApproval: e.target.checked }, 'Gespeichert.')} />Manuelle Freigabe erforderlich</label></div>
|
||||
<fieldset style={{ border: 0, padding: 0, margin: '16px 0 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Aktionen, die Kunden selbst ausführen dürfen</legend><div className="row">
|
||||
{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.customerActions.includes(a)} onChange={(e) => patch({ customerActions: e.target.checked ? [...d.customerActions, a] : d.customerActions.filter((x) => x !== a) }, 'Gespeichert. Gilt für neu bereitgestellte Ressourcen.')} />{ACTION_LABEL[a]}</label>)}</div></fieldset></div>
|
||||
<div className="card"><h2>Versionen</h2><div className="tablewrap"><table><thead><tr><th>Version</th><th>Bezeichnung</th><th>Einrichtung</th><th>Laufend</th><th>Steuer</th></tr></thead><tbody>
|
||||
{d.versions.map((v) => <tr key={v.id}><td>{v.version}{v.id === cur.id && ' (aktuell)'}</td><td>{v.name}</td><td>{eur(v.setupCents)}</td><td>{v.billingInterval === 'once' ? '–' : `${INTERVAL[v.billingInterval]} ${eur(v.recurringCents)}`}</td><td>{v.taxBp / 100} %</td></tr>)}</tbody></table></div></div>
|
||||
</>);
|
||||
}
|
||||
63
apps/web/src/app/(app)/admin/produkte/domains/page.tsx
Normal file
63
apps/web/src/app/(app)/admin/produkte/domains/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Field, eur } from '@/components/ui';
|
||||
|
||||
interface Margin { type: 'percent' | 'fixed' | null; value: number | null }
|
||||
interface Tld { tld: string; termMonths: number; costGrossCents: number; costNetCents: number; setupGrossCents: number; priceGrossCents: number | null; priceNetCents: number | null; profitNetCents: number | null; margin: Margin; active: boolean }
|
||||
interface Data { settings: { tier: number; margin: Margin; rounding: boolean; basis: 'gross' | 'net'; taxBp: number }; tlds: Tld[] }
|
||||
const TIERS = ['0–49 Domains', '50–199 Domains', '200–999 Domains', 'ab 1000 Domains'];
|
||||
const showMargin = (m: Margin) => m.type === 'percent' ? `${(m.value! / 100).toLocaleString('de-DE')} %` : m.type === 'fixed' ? eur(m.value!) : null;
|
||||
/** Eingabe (Prozent bzw. Euro, deutsches Komma) → Basispunkte bzw. Cent. */
|
||||
const toMargin = (type: string, raw: string): Margin => { if (!type || !raw.trim()) return { type: null, value: null }; const n = Number(raw.replace(',', '.')); if (!Number.isFinite(n) || n < 0) throw new Error('Bitte einen gültigen Aufschlag eingeben.'); return { type: type as 'percent' | 'fixed', value: Math.round(n * 100) }; };
|
||||
|
||||
function MarginInputs({ idp, def }: { idp: string; def: Margin }) {
|
||||
return (<div className="row" style={{ gap: 8 }}>
|
||||
<select id={`${idp}t`} name="mtype" defaultValue={def.type ?? ''} aria-label="Art des Aufschlags"><option value="">{idp === 'g' ? 'Kein Aufschlag' : 'Standard verwenden'}</option><option value="percent">Prozent vom Einkauf</option><option value="fixed">Festbetrag in €</option></select>
|
||||
<input id={`${idp}v`} name="mvalue" inputMode="decimal" style={{ width: 110 }} aria-label="Höhe des Aufschlags" defaultValue={def.value === null ? '' : String(def.value / 100).replace('.', ',')} placeholder="z. B. 25" /></div>);
|
||||
}
|
||||
export default function DomainPreise() {
|
||||
const { can } = useSession(); const w = can('domains.write');
|
||||
const [d, setD] = useState<Data | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [q, setQ] = useState(''); const [edit, setEdit] = useState<string | null>(null); const [imp, setImp] = useState(false);
|
||||
const load = useCallback(() => api<Data>('GET', '/admin/domain-tlds').then(setD).catch((e) => setErr(errMsg(e))), []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
const rows = useMemo(() => (d?.tlds ?? []).filter((t) => !q || t.tld.includes(q.trim().toLowerCase().replace(/^\./, ''))), [d, q]);
|
||||
async function saveSettings(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget); setErr(''); setOk('');
|
||||
try { await api('PUT', '/admin/domain-settings', { tier: Number(f.get('tier')), rounding: f.get('rounding') === 'on', basis: String(f.get('basis')), margin: toMargin(String(f.get('mtype')), String(f.get('mvalue'))) }); setOk('Einstellungen gespeichert. Alle Verkaufspreise wurden neu berechnet.'); void load(); } catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
async function saveTld(e: FormEvent<HTMLFormElement>, tld: string) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget); setErr(''); setOk('');
|
||||
try { await api('PATCH', `/admin/domain-tlds/${encodeURIComponent(tld)}`, { margin: toMargin(String(f.get('mtype')), String(f.get('mvalue'))) }); setEdit(null); void load(); } catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
async function toggle(t: Tld) { try { await api('PATCH', `/admin/domain-tlds/${encodeURIComponent(t.tld)}`, { active: !t.active }); void load(); } catch (x) { setErr(errMsg(x)); } }
|
||||
async function doImport(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); setOk('');
|
||||
try { const r = await api<{ rows: number; created: number; updated: number; skipped: string[] }>('POST', '/admin/domain-tlds/import', { text: String(new FormData(e.currentTarget).get('text')) }); setOk(`${r.rows} Endungen eingelesen (${r.created} neu, ${r.updated} aktualisiert${r.skipped.length ? `, ${r.skipped.length} Zeilen übersprungen` : ''}). Aufschläge bleiben erhalten.`); setImp(false); void load(); } catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
const noMargin = d && !d.settings.margin.type;
|
||||
return (<>
|
||||
<div className="row between"><h1>Domains</h1><div className="row"><Link className="btn" href="/admin/domains">Domain-Aufstellung</Link><Link className="btn" href="/admin/produkte">← Produkte</Link></div></div>
|
||||
<p className="muted">Domainpreise setzen sich zusammen aus <strong>Einkaufspreis + Ihr Gewinnaufschlag</strong>. Die Preisliste von KCS gilt als <strong>Bruttopreis (inkl. USt)</strong>. Darauf wird keine USt mehr aufgeschlagen; der Nettobetrag wird herausgerechnet. Im Angebot erscheint das Hostingprodukt plus die Domain mit dem Verkaufspreis.</p>
|
||||
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||
{noMargin && <Alert kind="warn">Es ist noch kein Standard-Aufschlag festgelegt. Solange fehlt bei Domains ohne eigenen Aufschlag der Verkaufspreis („Preis auf Anfrage“).</Alert>}
|
||||
{d && <form className="card" onSubmit={saveSettings}><h2>Preisstaffel und Standard-Aufschlag</h2>
|
||||
<div className="cols"><Field id="basis" label="Preise der Einkaufsliste" hint="Bei KCS sind es Bruttopreise."><select id="basis" name="basis" defaultValue={d.settings.basis} disabled={!w}><option value="gross">Brutto (inkl. USt)</option><option value="net">Netto (zzgl. USt)</option></select></Field><Field id="tier" label="Einkaufsstaffel" hint="Welche Staffel Ihrer Einkaufsliste gilt für Sie (nach Anzahl Ihrer Domains beim Anbieter)."><select id="tier" name="tier" defaultValue={d.settings.tier} disabled={!w}>{TIERS.map((t, i) => <option key={t} value={i + 1}>{t}</option>)}</select></Field>
|
||||
<div><label htmlFor="gt" style={{ fontWeight: 600 }}>Standard-Aufschlag</label><MarginInputs idp="g" def={d.settings.margin} /><p className="muted small">Gilt für alle Endungen ohne eigenen Aufschlag. Prozent in ganzen oder halben Schritten, z. B. 25 oder 12,5.</p></div></div>
|
||||
<label className="row" style={{ marginBottom: 12 }}><input type="checkbox" name="rounding" style={{ width: 20, minHeight: 20 }} defaultChecked={d.settings.rounding} disabled={!w} /> Verkaufspreise „gerade“ runden (brutto): ,00–,50 auf ,50 und ,51–,98 auf ,99 aufrunden (z. B. 12,00 → 12,50; 12,60 → 12,99)</label>
|
||||
{w && <button className="btn primary" type="submit">Speichern</button>}</form>}
|
||||
<div className="card"><div className="row between"><Field id="q" label="Endung suchen"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="z. B. de oder shop" /></Field>
|
||||
{w && <button className="btn" onClick={() => setImp(!imp)} aria-expanded={imp}>Preisliste einlesen</button>}</div>
|
||||
{imp && <form onSubmit={doImport}><Field id="text" label="Preisliste einfügen" hint="Zeilen: Endung, Laufzeit, vier Staffelpreise, optional Setup (Tabulator- oder Leerzeichen-getrennt, deutsches Zahlenformat möglich). Bestehende Aufschläge bleiben erhalten."><textarea id="text" name="text" rows={8} required style={{ width: '100%', fontFamily: 'monospace' }} /></Field><button className="btn primary" type="submit">Einlesen</button></form>}
|
||||
{!d ? <p className="muted" role="status">Wird geladen …</p> : <div className="tablewrap"><table><thead><tr><th>Endung</th><th>Laufzeit</th><th>Einkauf brutto</th><th>Einkauf netto</th><th>Aufschlag</th><th>Verkauf brutto</th><th>Verkauf netto</th><th>Gewinn netto</th><th>Setup brutto</th><th>Angebot</th>{w && <th></th>}</tr></thead><tbody>
|
||||
{rows.slice(0, q ? 500 : 150).map((t) => (<tr key={t.tld} style={t.active ? undefined : { opacity: .55 }}><td className="mono">.{t.tld}</td><td>{t.termMonths} Mon.</td><td>{eur(t.costGrossCents)}</td><td>{eur(t.costNetCents)}</td>
|
||||
<td>{edit === t.tld ? <form onSubmit={(e) => saveTld(e, t.tld)}><MarginInputs idp={`e-${t.tld}`} def={t.margin} /><div className="row"><button className="btn small primary" type="submit">OK</button><button className="btn small" type="button" onClick={() => setEdit(null)}>Abbrechen</button></div></form> : showMargin(t.margin) ?? <span className="muted small">Standard</span>}</td>
|
||||
<td>{t.priceGrossCents === null ? <span className="muted small">fehlt</span> : <strong>{eur(t.priceGrossCents)}</strong>}</td><td>{t.priceNetCents === null ? '–' : eur(t.priceNetCents)}</td><td>{t.profitNetCents === null ? '–' : eur(t.profitNetCents)}</td><td>{t.setupGrossCents ? eur(t.setupGrossCents) : '–'}</td>
|
||||
<td>{w ? <label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={t.active} onChange={() => toggle(t)} aria-label={`.${t.tld} anbieten`} /> {t.active ? 'ja' : 'nein'}</label> : t.active ? 'ja' : 'nein'}</td>
|
||||
{w && <td>{edit !== t.tld && <button className="btn small" onClick={() => setEdit(t.tld)}>Aufschlag</button>}</td>}</tr>))}</tbody></table></div>}
|
||||
{d && !q && rows.length > 150 && <p className="muted small">Die ersten 150 von {rows.length} Endungen. Mit der Suche finden Sie alle anderen.</p>}
|
||||
<p className="muted small">Die Einrichtungsgebühr (Setup) wird ohne Aufschlag weitergegeben. Die Rundung (,50/,99) gilt für den Verkaufspreis brutto.</p></div>
|
||||
</>);
|
||||
}
|
||||
63
apps/web/src/app/(app)/admin/produkte/hosting-tarif/page.tsx
Normal file
63
apps/web/src/app/(app)/admin/produkte/hosting-tarif/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use client';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Field, ACTION_LABEL, CUSTOMER_ACTIONS, INTERVAL } from '@/components/ui';
|
||||
|
||||
interface Conn { id: string; name: string; connector: string; capabilities: string[] }
|
||||
const LIMITS: [string, string, string][] = [['diskSpaceGb', 'Speicher (GB)', '10'], ['trafficGb', 'Traffic pro Monat (GB)', '100'], ['domains', 'Domains', '3'], ['subdomains', 'Subdomains', '10'], ['emailAccounts', 'Postfächer', '10'], ['emailAddresses', 'E-Mail-Adressen', '20'], ['emailForwardings', 'Weiterleitungen', '10'], ['databases', 'Datenbanken', '3'], ['ftpUsers', 'FTP-Benutzer', '3'], ['scheduledTasks', 'Cronjobs', '2']];
|
||||
const PERMS: [string, string][] = [['panel_access', 'Zugang zum Kundenpanel'], ['ftp', 'FTP'], ['php', 'PHP'], ['ssh', 'SSH'], ['backup', 'Backups'], ['file_manager', 'Dateimanager'], ['dns_editor', 'DNS-Editor'], ['certificate_management', 'SSL-Zertifikate verwalten'], ['domain_email', 'E-Mail-Domains'], ['email_catchall', 'Catch-all'], ['database_remote_access', 'Datenbank-Fernzugriff'], ['delete_main_domain', 'Hauptdomain löschen']];
|
||||
|
||||
export default function HostingTarif() {
|
||||
const { can } = useSession();
|
||||
const [conns, setConns] = useState<Conn[]>([]); const [connId, setConnId] = useState(''); const [tax, setTax] = useState<{ id: string; name: string; rateBp: number }[]>([]);
|
||||
const [interval, setInterval_] = useState('monthly'); const [basis, setBasis] = useState<'net' | 'gross'>('gross'); const [unl, setUnl] = useState<Record<string, boolean>>({});
|
||||
const [err, setErr] = useState(''); const [ok, setOk] = useState<{ id: string; name: string; features: string[] } | null>(null); const [busy, setBusy] = useState(false);
|
||||
useEffect(() => { api<Conn[]>('GET', '/admin/connectors').then((l) => { const c = l.filter((x) => x.capabilities.includes('catalog.write')); setConns(c); setConnId(c[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); api<typeof tax>('GET', '/admin/tax-rates').then(setTax).catch(() => undefined); }, []);
|
||||
if (!can('products.write')) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); setOk(null); setBusy(true); const f = new FormData(e.currentTarget); const g = (k: string) => (f.get(k) as string) ?? '';
|
||||
const limits = Object.fromEntries(LIMITS.map(([k]) => [k, unl[k] ? null : (g(k) === '' ? undefined : Number(g(k)))]).filter(([, v]) => v !== undefined));
|
||||
const permissions = Object.fromEntries(PERMS.map(([k]) => [k, f.get(`perm-${k}`) === 'on']));
|
||||
const once = interval === 'once';
|
||||
try {
|
||||
const r = await api<{ productId: string; plan: { name: string; features: string[] } }>('POST', `/admin/connectors/${connId}/hosting-plans`, {
|
||||
plan: { name: g('planName'), limits, permissions },
|
||||
product: { sku: g('sku'), status: f.get('activate') === 'on' ? 'active' : 'draft', orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', customerActions: CUSTOMER_ACTIONS.filter((a) => f.get(`act-${a}`) === 'on'),
|
||||
version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30) } } });
|
||||
setOk({ id: r.productId, name: r.plan.name, features: r.plan.features });
|
||||
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
return (<>
|
||||
<p><Link href="/admin/produkte">← Alle Produkte</Link></p>
|
||||
<h1>Neuen Hosting-Tarif anlegen</h1>
|
||||
<Alert kind="warn"><strong>Der Tarif wird sofort beim Anbieter (KeyHelp) angelegt.</strong> Größenangaben werden in Byte übermittelt; die Einheit ist in der API-Definition nicht angegeben. Bitte prüfen Sie den neuen Tarif einmal in KeyHelp nach, bevor Sie das Produkt aktivieren.</Alert>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{ok && <Alert kind="ok">Tarif „{ok.name}“ und Produkt wurden angelegt ({ok.features.join(' · ')}). <Link href={`/admin/produkte/${ok.id}`}>Produkt öffnen</Link></Alert>}
|
||||
{conns.length === 0 ? <div className="card"><p className="muted">Keine Verbindung, die Tarife anlegen kann (z. B. KeyHelp mit API-Schlüssel).</p></div> : (
|
||||
<form className="card" onSubmit={submit}>
|
||||
<div className="cols"><Field id="conn" label="Verbindung"><select id="conn" value={connId} onChange={(e) => setConnId(e.target.value)}>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
|
||||
<Field id="planName" label="Name des Tarifs in KeyHelp"><input id="planName" name="planName" required maxLength={100} /></Field></div>
|
||||
<h3>Ressourcen des Tarifs</h3>
|
||||
<div className="cols">{LIMITS.map(([k, label, ph]) => <div key={k}><Field id={k} label={label}><input id={k} name={k} type="number" min={0} step="1" placeholder={ph} disabled={!!unl[k]} required={!unl[k]} /></Field>
|
||||
<label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={!!unl[k]} onChange={(e) => setUnl({ ...unl, [k]: e.target.checked })} />unbegrenzt</label></div>)}</div>
|
||||
<p className="small muted">„Unbegrenzt“ wird nur angenommen, wenn die Instanz es bereits kennt; sonst meldet der Server das und Sie tragen eine Zahl ein.</p>
|
||||
<fieldset style={{ border: 0, padding: 0, margin: '16px 0' }}><legend style={{ fontWeight: 600, marginBottom: 8 }}>Rechte der Kunden im Panel</legend><div className="row">{PERMS.map(([k, l]) => <label key={k} className="row small"><input type="checkbox" name={`perm-${k}`} style={{ width: 20, minHeight: 20 }} defaultChecked={['panel_access', 'ftp', 'php', 'file_manager', 'backup', 'certificate_management', 'domain_email'].includes(k)} />{l}</label>)}</div></fieldset>
|
||||
<h3>Produkt im Kundencenter</h3>
|
||||
<div className="cols"><Field id="sku" label="Artikelnummer"><input id="sku" name="sku" required pattern="[A-Za-z0-9._\-]{2,50}" /></Field><Field id="name" label="Bezeichnung für Kunden" hint="Leer = Name des Tarifs"><input id="name" name="name" /></Field></div>
|
||||
<Field id="desc" label="Beschreibung"><input id="desc" name="desc" maxLength={2000} /></Field>
|
||||
<div className="cols"><Field id="basis" label="Preisangabe"><select id="basis" value={basis} onChange={(e) => setBasis(e.target.value as 'net' | 'gross')}><option value="gross">Brutto (Endkundenpreis)</option><option value="net">Netto</option></select></Field>
|
||||
<Field id="int" label="Abrechnung"><select id="int" value={interval} onChange={(e) => setInterval_(e.target.value)}>{Object.entries(INTERVAL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
|
||||
<Field id="setup" label={`Einrichtung (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id="setup" name="setup" type="number" min={0} step="0.01" defaultValue={0} /></Field>
|
||||
{interval !== 'once' && <Field id="recurring" label={`Preis ${INTERVAL[interval]} (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" required /></Field>}
|
||||
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === 1900)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
||||
{interval !== 'once' && <><Field id="term" label="Mindestlaufzeit (Monate)"><input id="term" name="term" type="number" min={0} defaultValue={0} /></Field><Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={30} /></Field></>}</div>
|
||||
{interval !== 'once' && <label className="row"><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label>}
|
||||
<h3>Verkauf</h3>
|
||||
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
|
||||
<fieldset style={{ border: 0, padding: 0, margin: '12px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Was Kunden am Hosting-Konto selbst tun dürfen</legend><div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} defaultChecked={['panel.login', 'domain.manage', 'email.manage'].includes(a)} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
|
||||
<div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Tarif anlegen und Produkt erstellen'}</button></div>
|
||||
</form>)}
|
||||
</>);
|
||||
}
|
||||
61
apps/web/src/app/(app)/admin/produkte/page.tsx
Normal file
61
apps/web/src/app/(app)/admin/produkte/page.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, eur, Field, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
||||
|
||||
export interface Prod { id: string; sku: string; category: string; status: string; connectorInstanceId: string | null; connectorName: string | null; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; provisioning: Record<string, unknown>;
|
||||
current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null }
|
||||
export const CAT: Record<string, string> = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' };
|
||||
const PSTATUS: Record<string, string> = { active: 'active', draft: 'invited', retired: 'closed' };
|
||||
export default function Produkte() {
|
||||
const { can } = useSession(); const w = can('products.write');
|
||||
const [list, setList] = useState<Prod[] | null>(null); const [conns, setConns] = useState<{ id: string; name: string; connector: string }[]>([]); const [tax, setTax] = useState<{ id: string; name: string; rateBp: number }[]>([]);
|
||||
const [basis, setBasis] = useState<'net' | 'gross'>('net'); const [open, setOpen] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [interval, setInterval_] = useState('yearly'); const [connId, setConnId] = useState('');
|
||||
const load = useCallback(() => api<Prod[]>('GET', '/admin/products').then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
|
||||
useEffect(() => { void load(); api<typeof tax>('GET', '/admin/tax-rates').then(setTax).catch(() => undefined); api<typeof conns>('GET', '/admin/connectors').then(setConns).catch(() => undefined); }, [load]);
|
||||
const conn = conns.find((c) => c.id === connId);
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget); const n = (k: string) => Math.round(Number(f.get(k) || 0));
|
||||
let provisioning: Record<string, unknown> = {};
|
||||
if (conn?.connector === 'licensing') provisioning = { programId: n('programId'), durationType: f.get('durationType'), userLimit: f.get('userLimit') ? n('userLimit') : null };
|
||||
const recurring = interval === 'once' ? 0 : Math.round(Number(f.get('recurring') || 0) * 100);
|
||||
try {
|
||||
await api('POST', '/admin/products', { sku: f.get('sku'), category: f.get('category'), connectorInstanceId: connId || null, orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', status: f.get('activate') === 'on' ? 'active' : 'draft',
|
||||
customerActions: [...CUSTOMER_ACTIONS].filter((a) => f.get(`act-${a}`) === 'on'),
|
||||
version: { name: f.get('name'), description: (f.get('description') as string) || undefined, taxRateId: f.get('tax'), priceBasis: basis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: recurring, billingInterval: interval, termMonths: interval === 'once' ? 0 : n('term'), renewal: interval !== 'once' && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: n('notice') || 30, provisioning } });
|
||||
setOpen(false); setMsg({ k: 'ok', t: 'Produkt angelegt.' }); void load();
|
||||
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
return (<>
|
||||
<div className="row between"><h1>Produkte</h1>{w && <div className="row"><Link className="btn primary" href="/admin/produkte/uebernahme">Aus Verbindung übernehmen</Link><Link className="btn" href="/admin/produkte/domains">Domains (Preisliste)</Link><Link className="btn" href="/admin/produkte/hosting-tarif">Neuen Hosting-Tarif anlegen</Link><button className="btn" aria-expanded={open} onClick={() => setOpen(!open)}>Manuell anlegen</button></div>}</div>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{open && <form className="card" onSubmit={create}><h2>Neues Produkt</h2>
|
||||
<div className="cols"><Field id="sku" label="Artikelnummer"><input id="sku" name="sku" required pattern="[A-Za-z0-9._\-]{2,50}" /></Field><Field id="name" label="Bezeichnung"><input id="name" name="name" required /></Field>
|
||||
<Field id="category" label="Kategorie"><select id="category" name="category">{Object.entries(CAT).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field></div>
|
||||
<Field id="description" label="Beschreibung (für Kunden sichtbar)"><input id="description" name="description" maxLength={2000} /></Field>
|
||||
<h3>Preis</h3>
|
||||
<div className="cols"><Field id="basis" label="Preisangabe"><select id="basis" value={basis} onChange={(e) => setBasis(e.target.value as 'net' | 'gross')}><option value="net">Netto</option><option value="gross">Brutto (bleibt exakt)</option></select></Field><Field id="interval" label="Abrechnung"><select id="interval" value={interval} onChange={(e) => setInterval_(e.target.value)}><option value="once">Einmalig</option><option value="monthly">Monatlich</option><option value="yearly">Jährlich</option></select></Field>
|
||||
<Field id="setup" label={`Einrichtung (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id="setup" name="setup" type="number" min={0} step="0.01" defaultValue={0} /></Field>
|
||||
{interval !== 'once' && <Field id="recurring" label={`Preis ${INTERVAL[interval]} (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" defaultValue={0} /></Field>}
|
||||
<Field id="tax" label="Steuersatz"><select id="tax" name="tax">{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field></div>
|
||||
{interval !== 'once' && <><h3>Laufzeit</h3><div className="cols"><Field id="term" label="Mindestlaufzeit (Monate)" hint="0 = keine"><input id="term" name="term" type="number" min={0} defaultValue={interval === 'yearly' ? 12 : 0} /></Field>
|
||||
<Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={30} /></Field>
|
||||
<label className="row" style={{ alignSelf: 'end', minHeight: 44 }}><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label></div></>}
|
||||
<h3>Bereitstellung</h3>
|
||||
<Field id="conn" label="Verbindung (leer = manuell, ohne Anbieter)"><select id="conn" value={connId} onChange={(e) => setConnId(e.target.value)}><option value="">— keine —</option>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
|
||||
{conn?.connector === 'licensing' && <div className="cols"><Field id="programId" label="Programm-ID im Lizenzsystem"><input id="programId" name="programId" type="number" min={1} required /></Field>
|
||||
<Field id="durationType" label="Lizenzdauer"><select id="durationType" name="durationType"><option value="YEAR">Jahr</option><option value="MONTH">Monat</option><option value="WEEK">Woche</option><option value="UNLIMITED">Unbegrenzt</option></select></Field>
|
||||
<Field id="userLimit" label="Benutzerlimit (leer = unbegrenzt)"><input id="userLimit" name="userLimit" type="number" min={1} /></Field></div>}
|
||||
<h3>Verkauf</h3>
|
||||
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
|
||||
<fieldset style={{ border: 0, padding: 0, margin: '16px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Aktionen, die Kunden an der Ressource selbst ausführen dürfen</legend>
|
||||
<div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
|
||||
<div className="row"><button className="btn primary" type="submit">Produkt anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
|
||||
<div className="card">{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Noch keine Produkte">Übernehmen Sie Produkte aus einer Verbindung oder legen Sie sie manuell an.</Empty> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Artikel</th><th>Bezeichnung</th><th>Preis</th><th>Bereitstellung</th><th>Status</th></tr></thead><tbody>
|
||||
{list.map((p) => <tr key={p.id}><td className="mono">{p.sku}</td><td><Link href={`/admin/produkte/${p.id}`}>{p.current?.name}</Link><div className="small muted">{CAT[p.category]}{p.orderableByCustomer ? ' · Selbstbestellung' : ''}</div></td>
|
||||
<td className="small">{p.current && <>{p.current.setupCents > 0 && <div>Einrichtung {eur(p.current.setupCents)} {p.current.priceBasis === 'gross' ? 'brutto' : 'netto'}</div>}<div>{p.current.billingInterval === 'once' ? 'einmalig' : `${INTERVAL[p.current.billingInterval]} ${eur(p.current.recurringCents)} ${p.current.priceBasis === 'gross' ? 'brutto' : 'netto'}`}</div></>}</td>
|
||||
<td>{p.connectorName ?? 'manuell'}</td><td><Status value={PSTATUS[p.status] ?? p.status} /></td></tr>)}</tbody></table></div>}</div></>);
|
||||
}
|
||||
149
apps/web/src/app/(app)/admin/produkte/uebernahme/page.tsx
Normal file
149
apps/web/src/app/(app)/admin/produkte/uebernahme/page.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
||||
|
||||
interface Item { fixed?: boolean; externalRef: string; name: string; description: string | null; category: string; group?: string; program?: string; features?: string[]; badge?: string | null; providerActive?: boolean; price?: { cents: number; currency: string }; interval?: 'once' | 'monthly' | 'yearly'; meta?: Record<string, string | number | null>; provisioning: Record<string, unknown>; hints?: { label: string; provisioning: Record<string, unknown>; count: number }[]; importedProducts: number }
|
||||
interface BundleProduct { sku: string; name: string; description?: string; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; taxBp: number; interval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; provisioning: Record<string, unknown>; exists: boolean }
|
||||
interface Bundle { key: string; name: string; description?: string; source?: string; products: BundleProduct[] }
|
||||
interface Conn { id: string; name: string; connector: string; capabilities: string[]; health: string }
|
||||
interface Tax { id: string; name: string; rateBp: number }
|
||||
const CAT: Record<string, string> = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' };
|
||||
const DUR: Record<string, string> = { YEAR: 'Jahr', MONTH: 'Monat', WEEK: 'Woche', UNLIMITED: 'Unbegrenzt' };
|
||||
const slug = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '').toUpperCase().slice(0, 30);
|
||||
/** Passende Abrechnung zur Lizenzdauer vorschlagen (änderbar). */
|
||||
const suggest = (d: string) => (d === 'YEAR' ? { interval: 'yearly', term: 12 } : d === 'MONTH' ? { interval: 'monthly', term: 0 } : { interval: 'once', term: 0 });
|
||||
|
||||
function ImportForm({ conn, item, tax, onDone, onCancel }: { conn: Conn; item: Item; tax: Tax[]; onDone: (id: string, sku: string) => void; onCancel: () => void }) {
|
||||
const lic = conn.connector === 'licensing';
|
||||
const first = (item.hints?.[0]?.provisioning ?? item.provisioning) as { durationType?: string; userLimit?: number | null };
|
||||
const fixed = lic && !!item.provisioning.productId; // Produkt des Lizenzsystems legt Dauer und Limit fest
|
||||
const providerFixed = !lic && item.fixed === true; // z. B. Hosting-Tarif: Provisionierung vom Anbieter vorgegeben
|
||||
const [dur, setDur] = useState(first.durationType ?? 'YEAR'); const [limit, setLimit] = useState<string>(first.userLimit == null ? '' : String(first.userLimit));
|
||||
const [interval, setInterval_] = useState<string>(item.interval ?? suggest(first.durationType ?? 'YEAR').interval); const [term, setTerm] = useState(suggest(first.durationType ?? 'YEAR').term);
|
||||
const [basis, setBasis] = useState<'net' | 'gross'>(item.price ? 'gross' : 'net');
|
||||
const price = item.price ? (item.price.cents / 100).toFixed(2) : '0';
|
||||
const descDefault = [item.description, item.features?.length ? `Enthalten: ${item.features.join(' · ')}` : ''].filter(Boolean).join(' – ');
|
||||
const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const [variant] = useState(item.importedProducts);
|
||||
const pick = (h: NonNullable<Item['hints']>[number]) => { const p = h.provisioning as { durationType?: string; userLimit?: number | null }; const d = p.durationType ?? 'YEAR'; setDur(d); setLimit(p.userLimit == null ? '' : String(p.userLimit)); const s = suggest(d); setInterval_(s.interval); setTerm(s.term); };
|
||||
const changeDur = (d: string) => { setDur(d); const s = suggest(d); setInterval_(s.interval); setTerm(s.term); };
|
||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); setBusy(true); const f = new FormData(e.currentTarget);
|
||||
const provisioning = lic ? { programId: Number(item.provisioning.programId), ...(fixed ? { productId: Number(item.provisioning.productId) } : {}), durationType: dur, userLimit: limit ? Number(limit) : null } : item.provisioning;
|
||||
const sku = f.get('sku') as string;
|
||||
try {
|
||||
const r = await api<{ id: string }>('POST', '/admin/products', { sku, category: f.get('category'), connectorInstanceId: conn.id, externalRef: item.externalRef,
|
||||
orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', status: f.get('activate') === 'on' ? 'active' : 'draft',
|
||||
customerActions: [...CUSTOMER_ACTIONS].filter((a) => f.get(`act-${a}`) === 'on'),
|
||||
version: { name: f.get('name'), description: (f.get('description') as string) || undefined, taxRateId: f.get('tax'), priceBasis: basis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: interval === 'once' ? 0 : Math.round(Number(f.get('recurring') || 0) * 100),
|
||||
billingInterval: interval, termMonths: interval === 'once' ? 0 : Number(f.get('term') || 0), renewal: interval !== 'once' && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(f.get('notice') || 30), provisioning } });
|
||||
onDone(r.id, sku);
|
||||
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
const id = (n: string) => `${item.externalRef}-${n}`;
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
<div className="cols"><Field id={id('name')} label="Bezeichnung für Kunden"><input id={id('name')} name="name" defaultValue={item.name} required /></Field>
|
||||
<Field id={id('sku')} label="Artikelnummer"><input id={id('sku')} name="sku" defaultValue={`${slug(conn.connector === 'licensing' ? 'LIC' : conn.connector)}-${slug(item.name)}${variant ? `-${variant + 1}` : ''}`} required pattern="[A-Za-z0-9._\-]{2,50}" /></Field>
|
||||
<Field id={id('cat')} label="Kategorie"><select id={id('cat')} name="category" defaultValue={item.category}>{Object.entries(CAT).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field></div>
|
||||
<Field id={id('desc')} label="Beschreibung (für Kunden sichtbar)"><input id={id('desc')} name="description" defaultValue={descDefault} maxLength={2000} /></Field>
|
||||
{fixed ? (<><h3>Vorgaben des Lizenzsystems</h3><p className="small muted">Diese Werte legt das Produkt im Lizenzsystem fest und werden bei der Bereitstellung übernommen.</p>
|
||||
<div className="cols"><Field id={id('pid')} label="Programm-ID"><input id={id('pid')} value={String(item.provisioning.programId)} readOnly /></Field><Field id={id('prid')} label="Produkt-ID"><input id={id('prid')} value={String(item.provisioning.productId)} readOnly /></Field>
|
||||
<Field id={id('dur')} label="Lizenzdauer"><input id={id('dur')} value={DUR[dur] ?? dur} readOnly /></Field><Field id={id('lim')} label="Benutzerlimit"><input id={id('lim')} value={limit || 'unbegrenzt'} readOnly /></Field></div>
|
||||
{item.meta?.modules && <p className="small muted">Module: {String(item.meta.modules)}</p>}</>)
|
||||
: providerFixed ? (<><h3>Vorgaben des Anbieters</h3><p className="small muted">Diese Werte legt der Tarif beim Anbieter fest und werden bei der Bereitstellung übernommen.</p>{item.features && item.features.length > 0 && <p className="small">{item.features.join(' · ')}</p>}<p className="small muted">Tarif-ID: <span className="mono">{String(item.provisioning.hostingPlanId ?? item.externalRef)}</span></p></>)
|
||||
: lic ? (<><h3>Lizenz-Vorgaben</h3>
|
||||
{item.hints && item.hints.length > 0 && <div className="row" style={{ marginBottom: 12 }} role="group" aria-label="Vorschläge aus bestehenden Lizenzen"><span className="small muted">Vorschläge aus bestehenden Lizenzen:</span>{item.hints.map((h) => <button type="button" className="btn small" key={h.label} onClick={() => pick(h)}>{h.label} ({h.count}×)</button>)}</div>}
|
||||
<div className="cols"><Field id={id('pid')} label="Programm-ID (vom Anbieter)"><input id={id('pid')} value={String(item.provisioning.programId)} readOnly /></Field>
|
||||
<Field id={id('dur')} label="Lizenzdauer"><select id={id('dur')} value={dur} onChange={(e) => changeDur(e.target.value)}>{Object.entries(DUR).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
|
||||
<Field id={id('lim')} label="Benutzerlimit" hint="Leer = unbegrenzt"><input id={id('lim')} type="number" min={1} value={limit} onChange={(e) => setLimit(e.target.value)} /></Field></div></>)
|
||||
: <p className="small muted">Vorgaben des Anbieters: <code>{JSON.stringify(item.provisioning)}</code></p>}
|
||||
<h3>Preis und Laufzeit</h3>
|
||||
{item.price && <p className="small muted">Preis aus dem Lizenzsystem übernommen ({(item.price.cents / 100).toLocaleString('de-DE', { style: 'currency', currency: item.price.currency })}). Das Lizenzsystem gibt nicht an, ob brutto oder netto – bitte prüfen (voreingestellt: brutto, Endkundenpreis).</p>}
|
||||
<div className="cols"><Field id={id('basis')} label="Preisangabe"><select id={id('basis')} value={basis} onChange={(e) => setBasis(e.target.value as 'net' | 'gross')}><option value="net">Netto (Steuer wird aufgeschlagen)</option><option value="gross">Brutto (Endkundenpreis, bleibt exakt)</option></select></Field>
|
||||
<Field id={id('int')} label="Abrechnung"><select id={id('int')} value={interval} onChange={(e) => setInterval_(e.target.value)}><option value="once">Einmalig</option><option value="monthly">Monatlich</option><option value="yearly">Jährlich</option></select></Field>
|
||||
<Field id={id('setup')} label={`Einrichtung (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id={id('setup')} name="setup" type="number" min={0} step="0.01" defaultValue={interval === 'once' ? price : 0} /></Field>
|
||||
{interval !== 'once' && <Field id={id('rec')} label={`Preis (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id={id('rec')} name="recurring" type="number" min={0} step="0.01" defaultValue={price} required /></Field>}
|
||||
<Field id={id('tax')} label="Steuersatz"><select id={id('tax')} name="tax" defaultValue={tax.find((t) => t.rateBp === 1900)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
||||
{interval !== 'once' && <><Field id={id('term')} label="Mindestlaufzeit (Monate)" hint="0 = keine"><input id={id('term')} name="term" type="number" min={0} value={term} onChange={(e) => setTerm(Number(e.target.value))} /></Field>
|
||||
<Field id={id('notice')} label="Kündigungsfrist (Tage)"><input id={id('notice')} name="notice" type="number" min={0} defaultValue={30} /></Field></>}</div>
|
||||
{interval !== 'once' && <label className="row"><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label>}
|
||||
<h3>Verkauf</h3>
|
||||
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
|
||||
<fieldset style={{ border: 0, padding: 0, margin: '12px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Aktionen, die Kunden an der Ressource selbst ausführen dürfen</legend>
|
||||
<div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
|
||||
<p className="small muted">Das Produkt wird als Entwurf angelegt, sofern „Sofort aktivieren“ nicht gewählt ist. Beim Anbieter wird dabei nichts verändert.</p>
|
||||
<div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Als Produkt übernehmen'}</button><button className="btn" type="button" onClick={onCancel}>Abbrechen</button></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const eur = (c: number) => (c / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
||||
const INT: Record<string, string> = { once: 'einmalig', monthly: 'monatlich', yearly: 'jährlich' };
|
||||
const EDITION = (p: Record<string, unknown>) => (p.keyPrefix ? String(p.keyPrefix) : p.keyPrefix === null ? 'UNLIMITED (ohne Präfix)' : '–');
|
||||
|
||||
/** Vorlagenpaket (z. B. Familytool-Editionen): Vorschau, Auswahl, Import als Entwürfe. */
|
||||
function BundlePanel({ conn, item, bundles, onDone, onCancel }: { conn: Conn; item: Item; bundles: Bundle[]; onDone: (msg: string) => void; onCancel: () => void }) {
|
||||
const [key, setKey] = useState(bundles[0]?.key ?? ''); const b = bundles.find((x) => x.key === key);
|
||||
const [sel, setSel] = useState<Record<string, boolean>>({}); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
|
||||
useEffect(() => { setSel(Object.fromEntries((b?.products ?? []).map((p) => [p.sku, !p.exists]))); }, [b]);
|
||||
const chosen = Object.keys(sel).filter((k) => sel[k]);
|
||||
async function go() {
|
||||
setBusy(true); setErr('');
|
||||
try { const r = await api<{ created: { sku: string }[]; skipped: { sku: string; reason: string }[] }>('POST', `/admin/product-bundles/${key}/import`, { connectorInstanceId: conn.id, programRef: item.externalRef, skus: chosen });
|
||||
onDone(`${r.created.length} Produkt(e) als Entwurf angelegt${r.skipped.length ? `, ${r.skipped.length} übersprungen (${r.skipped.map((x) => `${x.sku}: ${x.reason}`).join('; ')})` : ''}.`); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
if (!b) return <p className="muted">Keine Vorlagenpakete vorhanden.</p>;
|
||||
return (<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
<Field id={`b-${item.externalRef}`} label="Vorlagenpaket"><select id={`b-${item.externalRef}`} value={key} onChange={(e) => setKey(e.target.value)}>{bundles.map((x) => <option key={x.key} value={x.key}>{x.name}</option>)}</select></Field>
|
||||
{b.description && <p className="muted small">{b.description}</p>}
|
||||
<div className="tablewrap"><table><thead><tr><th><span className="sr">Auswahl</span></th><th>Produkt</th><th>Preis</th><th>Laufzeit</th><th>Edition</th></tr></thead><tbody>
|
||||
{b.products.map((p) => <tr key={p.sku}><td><input type="checkbox" style={{ width: 20, minHeight: 20 }} aria-label={`${p.name} auswählen`} checked={!!sel[p.sku]} disabled={p.exists} onChange={(e) => setSel({ ...sel, [p.sku]: e.target.checked })} /></td>
|
||||
<td>{p.name}<div className="small muted mono">{p.sku}{p.exists ? ' · bereits angelegt' : ''}</div></td>
|
||||
<td className="small">{p.setupCents > 0 && <div>Einrichtung {eur(p.setupCents)}</div>}{(p.interval !== 'once' || p.recurringCents > 0) && <div>{INT[p.interval]} {eur(p.recurringCents)}</div>}<div className="muted">{p.priceBasis === 'gross' ? 'brutto' : 'netto'}, {p.taxBp / 100} % USt.</div></td>
|
||||
<td className="small">{p.termMonths > 0 ? `${p.termMonths} Monate` : p.interval === 'once' ? 'unbefristet' : 'rollierend'}{p.renewal === 'auto' ? ', verlängert sich' : ''}</td><td className="small">{EDITION(p.provisioning)}{p.provisioning.validityDays ? `, ${String(p.provisioning.validityDays)} Tage` : ''}</td></tr>)}</tbody></table></div>
|
||||
<p className="small muted">Alle Produkte werden als <strong>Entwurf</strong> angelegt und lassen sich danach einzeln anpassen (Preis, Regeln) und aktivieren. Produkte mit Edition können erst aktiviert werden, wenn das Lizenzsystem die Edition-Erweiterung unterstützt. Beim Anbieter wird nichts verändert.</p>
|
||||
<div className="row"><button className="btn primary" disabled={busy || !chosen.length} onClick={go}>{busy ? 'Bitte warten …' : `${chosen.length} Produkt(e) übernehmen`}</button><button className="btn" onClick={onCancel}>Abbrechen</button></div></div>);
|
||||
}
|
||||
|
||||
export default function Uebernahme() {
|
||||
const { can } = useSession();
|
||||
const [conns, setConns] = useState<Conn[]>([]); const [connId, setConnId] = useState(''); const [tax, setTax] = useState<Tax[]>([]);
|
||||
const [bundles, setBundles] = useState<Bundle[]>([]); const [bopen, setBopen] = useState<string | null>(null); const [items, setItems] = useState<Item[] | null>(null); const [err, setErr] = useState(''); const [open, setOpen] = useState<string | null>(null); const [done, setDone] = useState<{ id: string; sku: string } | null>(null); const [msg, setMsg] = useState('');
|
||||
useEffect(() => {
|
||||
api<Conn[]>('GET', '/admin/connectors').then((l) => { const c = l.filter((x) => x.capabilities.includes('catalog.list')); setConns(c); setConnId(c[0]?.id ?? ''); if (!c.length) setItems([]); }).catch((e) => setErr(errMsg(e)));
|
||||
api<Tax[]>('GET', '/admin/tax-rates').then(setTax).catch(() => undefined);
|
||||
api<Bundle[]>('GET', '/admin/product-bundles').then(setBundles).catch(() => undefined);
|
||||
}, []);
|
||||
const load = useCallback(() => { if (!connId) return; setItems(null); setErr(''); api<Item[]>('GET', `/admin/connectors/${connId}/catalog`).then(setItems).catch((e) => { setErr(errMsg(e)); setItems([]); }); }, [connId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
if (!can('products.write')) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
||||
const conn = conns.find((c) => c.id === connId);
|
||||
return (<>
|
||||
<p><Link href="/admin/produkte">← Alle Produkte</Link></p>
|
||||
<h1>Produkte übernehmen</h1>
|
||||
<p className="muted">Die Angebote werden live aus der Verbindung gelesen. Sie legen hier Preis, Laufzeit, Steuer und Verkaufsregeln fest. Beim Anbieter wird nichts verändert.</p>
|
||||
{err && <Alert kind="err">{err} <button className="btn small" onClick={load}>Erneut versuchen</button></Alert>}
|
||||
{msg && <Alert kind="ok">{msg} <Link href="/admin/produkte">Zu den Produkten</Link></Alert>}
|
||||
{done && <Alert kind="ok">Produkt <strong>{done.sku}</strong> wurde angelegt. <Link href={`/admin/produkte/${done.id}`}>Produkt öffnen</Link></Alert>}
|
||||
<div className="card"><Field id="conn" label="Verbindung"><select id="conn" value={connId} onChange={(e) => { setConnId(e.target.value); setOpen(null); }}>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}{c.health !== 'ok' ? ' (gestört)' : ''}</option>)}</select></Field></div>
|
||||
{conns.length === 0 && items !== null && <div className="card"><Empty title="Keine geeignete Verbindung">Legen Sie unter „Verbindungen“ eine Verbindung an, die Produktübernahme unterstützt (z. B. das Lizenzsystem mit API-Zugang).</Empty></div>}
|
||||
{conn && items === null && <p className="muted" role="status">Angebote werden beim Anbieter gelesen …</p>}
|
||||
{conn && items && items.length === 0 && !err && <div className="card"><Empty title="Beim Anbieter wurden keine Angebote gefunden" /></div>}
|
||||
{conn && items?.map((it) => (
|
||||
<div className="card" key={it.externalRef}>
|
||||
<div className="row between"><h2 style={{ margin: 0 }}>{it.name}</h2>{it.importedProducts > 0 ? <span className="badge ok"><span aria-hidden="true">✓</span>{it.importedProducts} Produkt{it.importedProducts > 1 ? 'e' : ''} angelegt</span> : <span className="badge info"><span aria-hidden="true">○</span>Noch nicht übernommen</span>}</div>
|
||||
<p className="muted small">{it.program ? `${it.program} · ` : ''}{CAT[it.category]}{it.price ? ` · ${(it.price.cents / 100).toLocaleString('de-DE', { style: 'currency', currency: it.price.currency })}` : ''}{it.interval && it.price ? ` (${INT[it.interval]})` : ''} · Kennung: <span className="mono">{it.externalRef}</span>{it.description ? ` · ${it.description}` : ''}</p>
|
||||
{it.features && it.features.length > 0 && <p className="small">Enthalten: {it.features.join(' · ')}</p>}
|
||||
{it.providerActive === false && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Beim Anbieter inaktiv</span></p>}
|
||||
{bopen === it.externalRef ? <BundlePanel conn={conn} item={it} bundles={bundles} onCancel={() => setBopen(null)} onDone={(m) => { setBopen(null); setDone(null); setMsg(m); load(); }} />
|
||||
: open === it.externalRef ? <ImportForm conn={conn} item={it} tax={tax} onCancel={() => setOpen(null)} onDone={(id, sku) => { setOpen(null); setDone({ id, sku }); load(); }} />
|
||||
: <div className="row"><button className="btn" onClick={() => { setOpen(it.externalRef); setDone(null); setMsg(''); }}>{it.importedProducts > 0 ? 'Weitere Variante anlegen' : 'Übernehmen'}</button>
|
||||
{bundles.length > 0 && !it.provisioning.productId && <button className="btn" onClick={() => { setBopen(it.externalRef); setDone(null); setMsg(''); }}>Vorlagenpaket importieren</button>}</div>}
|
||||
</div>))}
|
||||
</>);
|
||||
}
|
||||
36
apps/web/src/app/(app)/bestellen/page.tsx
Normal file
36
apps/web/src/app/(app)/bestellen/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'use client';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, type Price, PriceText } from '@/components/ui';
|
||||
|
||||
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number }
|
||||
interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' }
|
||||
export default function Bestellen() {
|
||||
const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write');
|
||||
const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState<P[] | null>(null); const [sel, setSel] = useState<P | null>(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
|
||||
useEffect(() => { if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); else if (me?.organizations[0]) setOrg(me.organizations[0].id); }, [staff, me]);
|
||||
useEffect(() => { if (org) api<P[]>('GET', `/catalog?org=${org}`).then(setCat).catch((e) => setErr(errMsg(e))); }, [org]);
|
||||
const priv = (cat?.[0] as (P & { customerType?: string }) | undefined)?.customerType === 'private';
|
||||
async function order(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); if (!sel) return; setBusy(true); setErr(''); const f = new FormData(e.currentTarget);
|
||||
try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); }
|
||||
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
return (<>
|
||||
<h1>Neue Bestellung</h1>{err && <Alert kind="err">{err}</Alert>}
|
||||
{staff && <div className="card"><Field id="org" label="Kunde"><select id="org" value={org} onChange={(e) => { setOrg(e.target.value); setSel(null); }}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name} ({c.customerType === 'private' ? 'Privat' : 'Geschäft'})</option>)}</select></Field></div>}
|
||||
{cat === null ? <p className="muted" role="status">Wird geladen …</p> : cat.length === 0 ? <div className="card"><Empty title="Keine Produkte bestellbar">Aktuell sind keine Produkte für die Selbstbestellung freigegeben.</Empty></div> : (<>
|
||||
<div className="grid">{cat.map((p) => <div className="card" key={p.id} style={sel?.id === p.id ? { borderColor: 'var(--accent)' } : undefined}><h2>{p.name}</h2>{p.description && <p className="muted">{p.description}</p>}
|
||||
<div className="small"><PriceText p={p.price} priv={priv} /></div>
|
||||
<p className="small muted">{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage</p>
|
||||
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => setSel(p)}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>
|
||||
{sel && <form className="card" onSubmit={order}><h2>Bestellung prüfen</h2><p><strong>{sel.name}</strong></p><div className="small" style={{ marginBottom: 16 }}><PriceText p={sel.price} priv={priv} /></div>
|
||||
{staff && <Field id="discount" label="Rabatt in %" hint="Nur Personal. Der Preis wird serverseitig berechnet."><input id="discount" name="discount" type="number" min={0} max={100} step="0.01" defaultValue={0} /></Field>}
|
||||
<Field id="note" label="Anmerkung (optional)"><input id="note" name="note" maxLength={500} /></Field>
|
||||
<p className="small muted">{staff ? 'Die Bestellung wird sofort freigegeben und bereitgestellt.' : sel.requiresApproval ? 'Ihre Bestellung wird von uns geprüft und freigegeben. Sie sehen den Stand unter „Bestellungen“.' : 'Die Bereitstellung startet sofort.'}</p>
|
||||
<button className="btn primary" disabled={busy} type="submit">{busy ? 'Bitte warten …' : 'Verbindlich bestellen'}</button></form>}
|
||||
</>)}
|
||||
</>);
|
||||
}
|
||||
33
apps/web/src/app/(app)/bestellungen/page.tsx
Normal file
33
apps/web/src/app/(app)/bestellungen/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, fmt, OrderStatusBadge, type Price, PriceText } from '@/components/ui';
|
||||
|
||||
interface O { id: string; number: string; orgName: string; status: string; createdAt: string; failureNote: string | null; failureAmbiguous: boolean; note: string | null; items: { id: string; quantity: number; contractId: string | null; snapshot: Price & { name: string; customerType: string } }[] }
|
||||
export default function Bestellungen() {
|
||||
const { can, me } = useSession(); const staff = can('orders.read'); const approver = can('orders.approve');
|
||||
const [list, setList] = useState<O[] | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [busy, setBusy] = useState(''); const [chk, setChk] = useState<Record<string, boolean>>({});
|
||||
const load = useCallback(() => api<O[]>('GET', '/orders').then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
|
||||
useEffect(() => { void load(); const t = setInterval(() => void load(), 5000); return () => clearInterval(t); }, [load]);
|
||||
async function act(o: O, path: string, body?: unknown, ok = 'Erledigt.') { setBusy(o.id); try { await api('POST', path, body); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(''); } }
|
||||
const canOrder = !!me && (staff ? can('orders.write') : me.organizations.some((o) => o.role !== 'member'));
|
||||
return (<>
|
||||
<div className="row between"><h1>Bestellungen</h1>{canOrder && <Link className="btn primary" href="/bestellen">Neue Bestellung</Link>}</div>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <div className="card"><Empty title="Keine Bestellungen" /></div> : list.map((o) => (
|
||||
<div className="card" key={o.id}><div className="row between"><h2 style={{ margin: 0 }}><span className="mono">{o.number}</span>{staff && <span className="muted"> · {o.orgName}</span>}</h2><OrderStatusBadge value={o.status} /></div>
|
||||
<p className="small muted">Bestellt am {fmt(o.createdAt)}{o.note ? ` · „${o.note}“` : ''}</p>
|
||||
{o.items.map((i) => <div key={i.id} style={{ marginBottom: 8 }}><strong>{i.quantity} × {i.snapshot.name}</strong>{i.contractId && <> · <Link href={`/vertraege/${i.contractId}`}>Vertrag</Link></>}<div className="small"><PriceText p={i.snapshot} priv={i.snapshot.customerType === 'private'} /></div></div>)}
|
||||
{o.status === 'failed' && o.failureNote && <Alert kind="err">{o.failureNote}</Alert>}
|
||||
{o.status === 'provisioning' && <p className="small muted" role="status">Die Bereitstellung läuft im Hintergrund. Diese Seite aktualisiert sich automatisch.</p>}
|
||||
<div className="row">
|
||||
{approver && o.status === 'pending_approval' && <><button className="btn primary" disabled={busy === o.id} onClick={() => act(o, `/admin/orders/${o.id}/approve`, undefined, 'Bestellung freigegeben, die Bereitstellung wurde gestartet.')}>Freigeben</button>
|
||||
<button className="btn danger" disabled={busy === o.id} onClick={() => act(o, `/admin/orders/${o.id}/reject`, {}, 'Bestellung abgelehnt.')}>Ablehnen</button></>}
|
||||
{approver && o.status === 'failed' && <>{o.failureAmbiguous && <label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={!!chk[o.id]} onChange={(e) => setChk({ ...chk, [o.id]: e.target.checked })} />Ich habe beim Anbieter geprüft, dass nichts angelegt wurde</label>}
|
||||
<button className="btn" disabled={busy === o.id || (o.failureAmbiguous && !chk[o.id])} onClick={() => act(o, `/admin/orders/${o.id}/retry`, { confirmChecked: !!chk[o.id] }, 'Die Bereitstellung wurde erneut gestartet.')}>Erneut versuchen</button></>}
|
||||
{(o.status === 'pending_approval' || (can('orders.write') && ['approved', 'failed'].includes(o.status))) && <button className="btn" disabled={busy === o.id} onClick={() => act(o, `/orders/${o.id}/cancel`, undefined, 'Bestellung storniert.')}>{staff ? 'Stornieren' : 'Zurückziehen'}</button>}
|
||||
</div></div>))}
|
||||
</>);
|
||||
}
|
||||
50
apps/web/src/app/(app)/dashboard/page.tsx
Normal file
50
apps/web/src/app/(app)/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty } from '@/components/ui';
|
||||
|
||||
interface Sys { jobs: Record<string, number>; oldestPendingJob: string | null; backup?: { configured: boolean; lastRunAt?: string | null; ok?: boolean; ageHours?: number | null; stale?: boolean; error?: string | null; sizeBytes?: number | null; targets?: { name: string; ok: boolean }[]; lastRestoreTestAt?: string | null; restoreOk?: boolean | null; restoreStale?: boolean } }
|
||||
export default function Dashboard() {
|
||||
const { me, can } = useSession();
|
||||
const [mine, setMine] = useState<{ contracts: { status: string; cancelEffectiveAt: string | null }[]; resources: { state: string; stale: boolean }[]; orders: { status: string }[] } | null>(null);
|
||||
const [customers, setCustomers] = useState<number | null>(null); const [sys, setSys] = useState<Sys | null>(null); const [err, setErr] = useState('');
|
||||
useEffect(() => {
|
||||
if (me?.kind === 'customer') {
|
||||
Promise.all([api<any[]>('GET', '/contracts'), api<any[]>('GET', '/resources'), api<any[]>('GET', '/orders')]).then(([contracts, resources, orders]) => setMine({ contracts, resources, orders })).catch((e) => setErr(errMsg(e)));
|
||||
return;
|
||||
}
|
||||
if (!me || me.kind !== 'staff') return;
|
||||
if (can('customers.read')) api<unknown[]>('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e)));
|
||||
if (can('jobs.read')) api<Sys>('GET', '/admin/system').then(setSys).catch((e) => setErr(errMsg(e)));
|
||||
}, [me, can]);
|
||||
if (!me) return null;
|
||||
const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0;
|
||||
return (<>
|
||||
<h1>Hallo {me.user.name.split(' ')[0]}</h1>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{me.kind === 'customer' ? (<>
|
||||
{me.organizations.map((o) => (
|
||||
<div className="card" key={o.id}><h2>{o.name}</h2><p className="muted">Kundennummer {o.customerNumber}</p><Link className="btn" href="/organisation">Organisation ansehen</Link></div>
|
||||
))}
|
||||
{mine && mine.contracts.length === 0 && mine.resources.length === 0 && mine.orders.length === 0 && <div className="card"><Empty title="Noch keine Produkte">Sobald Produkte, Verträge, Rechnungen oder Tickets für Ihre Organisation vorhanden sind, erscheinen sie hier.</Empty></div>}
|
||||
{mine && (mine.contracts.length > 0 || mine.resources.length > 0 || mine.orders.length > 0) && <div className="grid">
|
||||
<div className="card"><div className="stat">{mine.contracts.filter((c) => c.status === 'active').length}</div><p className="muted">Aktive Verträge</p>{mine.contracts.some((c) => c.cancelEffectiveAt) && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Gekündigter Vertrag vorhanden</span></p>}<Link href="/vertraege">Verträge ansehen</Link></div>
|
||||
<div className="card"><div className="stat">{mine.resources.length}</div><p className="muted">Ressourcen und Lizenzen</p>{mine.resources.some((r) => r.stale) && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Stand teilweise veraltet</span></p>}<Link href="/ressourcen">Ressourcen ansehen</Link></div>
|
||||
{mine.orders.filter((o) => ['pending_approval', 'provisioning', 'failed'].includes(o.status)).length > 0 && <div className="card"><div className="stat">{mine.orders.filter((o) => ['pending_approval', 'provisioning', 'failed'].includes(o.status)).length}</div><p className="muted">Bestellungen in Bearbeitung</p><Link href="/bestellungen">Bestellungen ansehen</Link></div>}
|
||||
</div>}
|
||||
</>) : (<>
|
||||
<div className="grid">
|
||||
{customers !== null && <div className="card"><div className="stat">{customers}</div><p className="muted">Kunden</p><Link href="/admin/kunden">Kunden verwalten</Link></div>}
|
||||
{sys && <div className="card"><div className="stat">{sys.jobs.scheduled ?? 0}</div><p className="muted">Wartende Aufträge</p></div>}
|
||||
{sys?.backup && <div className="card"><h3>Backup</h3><p className="small"><Link href="/einstellungen/backup">Details und Einstellungen</Link></p>
|
||||
{!sys.backup.configured ? <><p className="muted">Nicht eingerichtet.</p><span className="badge warn"><span aria-hidden="true">▲</span>Kein Backup</span></>
|
||||
: <><p>{sys.backup.stale ? <span className="badge err"><span aria-hidden="true">✕</span>{sys.backup.ok ? 'Veraltet' : 'Fehlgeschlagen'}</span> : <span className="badge ok"><span aria-hidden="true">✓</span>Aktuell</span>}</p>
|
||||
<p className="small muted">Letzter Lauf: {sys.backup.lastRunAt ? new Date(sys.backup.lastRunAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) : '–'}{sys.backup.sizeBytes ? ` · ${(sys.backup.sizeBytes / 1024).toFixed(0)} KB` : ''}<br />Ziele: {sys.backup.targets?.length ? sys.backup.targets.map((t) => `${t.ok ? '✓' : '✕'} ${t.name}`).join(', ') : '–'}<br />Wiederherstellungstest: {sys.backup.lastRestoreTestAt ? `${sys.backup.restoreOk ? '✓' : '✕'} ${new Date(sys.backup.lastRestoreTestAt).toLocaleDateString('de-DE')}` : 'noch nie'}{sys.backup.restoreStale ? ' (überfällig)' : ''}</p>
|
||||
{sys.backup.error && <p className="small" style={{ color: 'var(--err)' }}>{sys.backup.error}</p>}</>}</div>}
|
||||
{sys && <div className="card"><div className="stat">{failed}</div><p className="muted">Aufträge mit Fehlern / Prüfbedarf</p>{failed > 0 && <span className="badge err"><span aria-hidden="true">✕</span>Prüfen</span>}</div>}
|
||||
</div>
|
||||
</>)}
|
||||
</>);
|
||||
}
|
||||
29
apps/web/src/app/(app)/domains/page.tsx
Normal file
29
apps/web/src/app/(app)/domains/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
'use client';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Alert, Field, eur } from '@/components/ui';
|
||||
|
||||
interface Result { domain: string; tld: string; status: 'available' | 'registered' | 'unknown'; method: 'rdap' | 'dns'; note?: string; offered: boolean; offer: { termMonths: number; priceGrossCents: number; priceNetCents: number; taxBp: number; setupGrossCents: number } | null }
|
||||
const LABEL = { available: { cls: 'ok', icon: '✓', text: 'Frei' }, registered: { cls: 'err', icon: '✕', text: 'Vergeben' }, unknown: { cls: 'warn', icon: '?', text: 'Unklar' } } as const;
|
||||
|
||||
export default function DomainPruefen() {
|
||||
const [q, setQ] = useState(''); const [res, setRes] = useState<Result[] | null>(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
|
||||
async function check(e: FormEvent) {
|
||||
e.preventDefault(); setErr(''); setBusy(true); setRes(null);
|
||||
try { setRes((await api<{ results: Result[] }>('GET', `/domains/check?name=${encodeURIComponent(q.trim())}`)).results); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
return (<>
|
||||
<h1>Domain prüfen</h1>
|
||||
<form className="card" onSubmit={check}>
|
||||
<Field id="dq" label="Wunschdomain" hint="Mit Endung (z. B. beispiel.de) oder nur der Name – dann prüfen wir gängige Endungen."><input id="dq" value={q} onChange={(e) => setQ(e.target.value)} placeholder="beispiel.de" required autoFocus autoComplete="off" /></Field>
|
||||
<button className="btn primary" type="submit" disabled={busy || !q.trim()}>{busy ? 'Wird geprüft …' : 'Prüfen'}</button>
|
||||
</form>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{res && <div className="card" aria-live="polite"><div className="tablewrap"><table><thead><tr><th>Domain</th><th>Status</th><th>Preis</th></tr></thead><tbody>
|
||||
{res.map((r) => { const l = LABEL[r.status]; return (<tr key={r.domain}><td className="mono">{r.domain}</td>
|
||||
<td><span className={`badge ${l.cls}`}><span aria-hidden="true">{l.icon}</span>{l.text}</span>{r.note && <div className="muted small">{r.note}</div>}{r.status === 'unknown' && <div className="muted small">Der Status konnte nicht ermittelt werden. Bitte später erneut versuchen.</div>}</td>
|
||||
<td>{!r.offered ? <span className="muted small">Endung wird nicht angeboten</span> : r.offer ? <>{eur(r.offer.priceGrossCents)} <span className="muted small">inkl. {r.offer.taxBp / 100} % USt ({eur(r.offer.priceNetCents)} netto) für {r.offer.termMonths} Monate{r.offer.setupGrossCents ? `, einmalig ${eur(r.offer.setupGrossCents)} Einrichtung` : ''}</span></> : <span className="muted small">Preis auf Anfrage</span>}</td></tr>); })}
|
||||
</tbody></table></div>
|
||||
<p className="muted small">Die Auskunft stammt direkt von der Registry (RDAP) oder ersatzweise aus dem DNS. Vergeben wird die Domain erst mit der Bestellung; bis dahin kann sie jemand anderes registrieren.</p></div>}
|
||||
</>);
|
||||
}
|
||||
79
apps/web/src/app/(app)/einstellungen/backup/page.tsx
Normal file
79
apps/web/src/app/(app)/einstellungen/backup/page.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, fmt } from '@/components/ui';
|
||||
import { BackupPassword, type EncryptionInfo } from '@/components/BackupPassword';
|
||||
import { BackupTargets, type TargetInfo } from '@/components/BackupTargets';
|
||||
|
||||
interface Target { name: string; ok: boolean; error?: string }
|
||||
interface Run { at: string; ok: boolean; file?: string; sizeBytes?: number; durationMs: number; label?: string; error?: string; targets?: Target[] }
|
||||
interface Test { at: string; ok: boolean; file?: string; durationMs: number; checks: Record<string, string | number | boolean>; error?: string }
|
||||
interface State { configured: boolean; running: { action: string; since: string } | null; pendingRequests: string[]; lastRun: Run | null; lastRestoreTest: Test | null; history: Run[]; stale: boolean; restoreStale: boolean;
|
||||
schedule: { backup: string; restoreTest: string }; retention: { daily: number; weekly: number; monthly: number }; localDir: string; remotes: string[]; hasExternalTarget: boolean }
|
||||
const LABEL: Record<string, string> = { daily: 'täglich', weekly: 'wöchentlich', monthly: 'monatlich' };
|
||||
const CHECK: Record<string, string> = { pruefsumme: 'Prüfsumme der Sicherung', dumpPruefsumme: 'Prüfsumme des Datenbank-Dumps', zeilenzahlen: 'Zeilenzahlen aller Kerntabellen', migrationen: 'Datenbank-Migrationen', auditKette: 'Audit-Hash-Kette', geheimnisseOk: 'Gesicherte Geheimnisse entschlüsselbar' };
|
||||
const size = (b?: number) => (b == null ? '–' : b < 1024 * 1024 ? `${Math.max(1, Math.round(b / 1024))} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`);
|
||||
const dur = (ms: number) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
|
||||
|
||||
export default function BackupPage() {
|
||||
const { can } = useSession(); const canRun = can('backup.run'); const canSecrets = can('backup.secrets');
|
||||
const [cfg, setCfg] = useState<{ encryption: EncryptionInfo; targets: TargetInfo[] } | null>(null);
|
||||
const loadCfg = useCallback(() => api<{ encryption: EncryptionInfo; targets: TargetInfo[] }>('GET', '/admin/backup/settings').then(setCfg).catch(() => undefined), []);
|
||||
useEffect(() => { void loadCfg(); }, [loadCfg]);
|
||||
const [s, setS] = useState<State | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [busy, setBusy] = useState(false);
|
||||
const load = useCallback(() => api<State>('GET', '/admin/backup').then(setS).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
|
||||
const active = !!s?.running || !!s?.pendingRequests.length;
|
||||
useEffect(() => { void load(); const t = setInterval(() => void load(), active ? 2500 : 20000); return () => clearInterval(t); }, [load, active]);
|
||||
async function request(action: 'backup' | 'restore-test') {
|
||||
setBusy(true); setMsg(null);
|
||||
try { await api('POST', '/admin/backup/run', { action }); setMsg({ k: 'ok', t: action === 'backup' ? 'Die Sicherung wurde angefordert und startet in wenigen Sekunden.' : 'Der Wiederherstellungstest wurde angefordert und startet in wenigen Sekunden.' }); void load(); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
|
||||
}
|
||||
if (!s) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||
const r = s.lastRun; const t = s.lastRestoreTest;
|
||||
return (<>
|
||||
<h2>Backup</h2>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{!s.hasExternalTarget && <Alert kind="warn"><strong>Kein externes Ziel eingerichtet.</strong> Die Sicherungen liegen nur lokal auf diesem Server und wären bei dessen Verlust mit verloren. Legen Sie unten unter „Externe Ziele“ ein Ziel an (SFTP, FTP, Google Drive oder Netzlaufwerk).</Alert>}
|
||||
{s.running && <Alert kind="info"><span role="status">{s.running.action === 'backup' ? 'Sicherung läuft' : 'Wiederherstellungstest läuft'} seit {fmt(s.running.since)} …</span></Alert>}
|
||||
{!s.running && s.pendingRequests.length > 0 && <Alert kind="info"><span role="status">Anfrage liegt vor, der Vorgang startet gleich …</span></Alert>}
|
||||
|
||||
<div className="grid">
|
||||
<div className="card"><h3>Sicherung</h3>
|
||||
{!r ? <><p className="muted">Noch nie gelaufen.</p><span className="badge warn"><span aria-hidden="true">▲</span>Keine Sicherung</span></>
|
||||
: <><p>{s.stale ? <span className="badge err"><span aria-hidden="true">✕</span>{r.ok ? 'Veraltet' : 'Fehlgeschlagen'}</span> : <span className="badge ok"><span aria-hidden="true">✓</span>Aktuell</span>}</p>
|
||||
<p className="small muted">Letzter Lauf: {fmt(r.at)}<br />Größe {size(r.sizeBytes)} · Dauer {dur(r.durationMs)}{r.label ? ` · ${LABEL[r.label] ?? r.label}` : ''}</p>
|
||||
{r.error && <p className="small" style={{ color: 'var(--err)' }}>{r.error}</p>}</>}
|
||||
<p className="small muted">Zeitplan: {s.schedule.backup}</p>
|
||||
{canRun && <button className="btn primary" disabled={busy || active} onClick={() => request('backup')}>Jetzt sichern</button>}</div>
|
||||
<div className="card"><h3>Wiederherstellungstest</h3>
|
||||
{!t ? <><p className="muted">Noch nie gelaufen.</p><span className="badge warn"><span aria-hidden="true">▲</span>Kein Test</span></>
|
||||
: <><p>{t.ok && !s.restoreStale ? <span className="badge ok"><span aria-hidden="true">✓</span>Bestanden</span> : t.ok ? <span className="badge warn"><span aria-hidden="true">▲</span>Überfällig</span> : <span className="badge err"><span aria-hidden="true">✕</span>Fehlgeschlagen</span>}</p>
|
||||
<p className="small muted">Letzter Test: {fmt(t.at)} · Dauer {dur(t.durationMs)}</p>{t.error && <p className="small" style={{ color: 'var(--err)' }}>{t.error}</p>}</>}
|
||||
<p className="small muted">Zeitplan: {s.schedule.restoreTest}. Die Sicherung wird in eine Wegwerf-Datenbank eingespielt, Produktivdaten bleiben unberührt.</p>
|
||||
{canRun && <button className="btn" disabled={busy || active} onClick={() => request('restore-test')}>Jetzt testen</button>}</div>
|
||||
</div>
|
||||
|
||||
{cfg && <BackupPassword info={cfg.encryption} canEdit={canSecrets} onChanged={(auto) => { void loadCfg(); if (auto) void request('backup'); }} />}
|
||||
{cfg && <BackupTargets targets={cfg.targets} canEdit={canSecrets} onChanged={() => { void loadCfg(); void load(); }} />}
|
||||
|
||||
{t && t.checks && Object.keys(CHECK).some((k) => k in t.checks) && <div className="card"><h3>Prüfungen des letzten Tests</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>{Object.entries(CHECK).filter(([k]) => k in t.checks).map(([k, label]) => <li key={k}><span className={`badge ${t.checks[k] ? 'ok' : 'err'}`}><span aria-hidden="true">{t.checks[k] ? '✓' : '✕'}</span>{t.checks[k] ? 'OK' : 'Fehler'}</span> {label}{k === 'auditKette' && t.checks.auditEintraege ? ` (${String(t.checks.auditEintraege)} Einträge)` : ''}</li>)}</ul></div>}
|
||||
|
||||
<div className="card"><h3>Ergebnis je Ziel (letzter Lauf)</h3>
|
||||
<div className="tablewrap"><table><thead><tr><th>Ziel</th><th>Letzter Lauf</th></tr></thead><tbody>
|
||||
<tr><td>Lokal <span className="mono small muted">{s.localDir}</span></td><td>{r?.targets?.[0] ? <span className={`badge ${r.targets[0].ok ? 'ok' : 'err'}`}><span aria-hidden="true">{r.targets[0].ok ? '✓' : '✕'}</span>{r.targets[0].ok ? 'OK' : 'Fehler'}</span> : '–'}</td></tr>
|
||||
{s.remotes.map((n) => { const res = r?.targets?.find((x) => x.name === n); return <tr key={n}><td className="mono">{n}</td><td>{res ? <><span className={`badge ${res.ok ? 'ok' : 'err'}`}><span aria-hidden="true">{res.ok ? '✓' : '✕'}</span>{res.ok ? 'OK' : 'Fehler'}</span>{res.error && <div className="small" style={{ color: 'var(--err)' }}>{res.error}</div>}</> : '–'}</td></tr>; })}
|
||||
{s.remotes.length === 0 && <tr><td colSpan={2} className="muted small">Kein externes Ziel konfiguriert.</td></tr>}</tbody></table></div>
|
||||
<p className="small muted" style={{ marginTop: 12 }}>Aufbewahrung: {s.retention.daily} tägliche, {s.retention.weekly} wöchentliche, {s.retention.monthly} monatliche Sicherungen (mindestens 3 bleiben immer erhalten). Alle Dateien sind verschlüsselt.</p></div>
|
||||
|
||||
<div className="card"><h3>Verlauf</h3>
|
||||
{s.history.length === 0 ? <Empty title="Noch keine Sicherungen" /> : <div className="tablewrap"><table><thead><tr><th>Zeitpunkt</th><th>Art</th><th>Größe</th><th>Dauer</th><th>Ergebnis</th></tr></thead><tbody>
|
||||
{s.history.map((h) => <tr key={h.at}><td>{fmt(h.at)}</td><td>{h.label ? LABEL[h.label] ?? h.label : '–'}</td><td>{size(h.sizeBytes)}</td><td>{dur(h.durationMs)}</td><td><span className={`badge ${h.ok ? 'ok' : 'err'}`}><span aria-hidden="true">{h.ok ? '✓' : '✕'}</span>{h.ok ? 'Erfolgreich' : 'Fehlgeschlagen'}</span>{h.error && <div className="small" style={{ color: 'var(--err)' }}>{h.error}</div>}</td></tr>)}</tbody></table></div>}</div>
|
||||
|
||||
<div className="card"><h3>Wichtig</h3>
|
||||
<p>Die Sicherungen sind nur mit dem <strong>Backup-Passwort</strong> lesbar (bzw. bei älteren Sicherungen mit der Schlüsseldatei <span className="mono">/etc/kundencenter/backup-age-key.txt</span>). Bewahren Sie das Passwort <strong>außerhalb</strong> dieses Servers auf (z. B. Passwortmanager), sonst sind die Sicherungen bei Serververlust nicht lesbar.</p>
|
||||
<p className="small muted" style={{ margin: 0 }}>Wiederherstellung im Notfall: siehe <span className="mono">docs/betrieb-backup-restore.md</span>. Das Lizenzsystem ist nicht Teil dieser Sicherung.</p></div>
|
||||
</>);
|
||||
}
|
||||
23
apps/web/src/app/(app)/einstellungen/layout.tsx
Normal file
23
apps/web/src/app/(app)/einstellungen/layout.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert } from '@/components/ui';
|
||||
|
||||
/** Einstellungen: ein Bereich mit Unterpunkten. Weitere Unterpunkte werden hier ergänzt. */
|
||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||
const { can } = useSession(); const path = usePathname();
|
||||
const tabs = [
|
||||
{ href: '/einstellungen/verbindungen', label: 'Verbindungen', show: can('connectors.read') },
|
||||
{ href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') },
|
||||
].filter((t) => t.show);
|
||||
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
||||
return (<>
|
||||
<h1>Einstellungen</h1>
|
||||
<nav aria-label="Einstellungen" className="row" style={{ gap: 8, marginBottom: 24, borderBottom: '1px solid var(--border)', paddingBottom: 8 }}>
|
||||
{tabs.map((t) => { const cur = path === t.href || path.startsWith(t.href + '/'); return <Link key={t.href} href={t.href} aria-current={cur ? 'page' : undefined} className="btn" style={cur ? { background: 'var(--surface-2)', fontWeight: 600, borderColor: 'var(--accent)' } : undefined}>{t.label}</Link>; })}
|
||||
</nav>
|
||||
{children}
|
||||
</>);
|
||||
}
|
||||
9
apps/web/src/app/(app)/einstellungen/page.tsx
Normal file
9
apps/web/src/app/(app)/einstellungen/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSession } from '@/lib/session';
|
||||
export default function SettingsIndex() {
|
||||
const { can, me } = useSession(); const r = useRouter();
|
||||
useEffect(() => { if (me) r.replace(can('connectors.read') ? '/einstellungen/verbindungen' : '/einstellungen/backup'); }, [me, can, r]);
|
||||
return <p className="muted" role="status">Wird geladen …</p>;
|
||||
}
|
||||
50
apps/web/src/app/(app)/einstellungen/verbindungen/page.tsx
Normal file
50
apps/web/src/app/(app)/einstellungen/verbindungen/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, fmt } from '@/components/ui';
|
||||
import { ConfigFields, type CfgField } from '@/components/ConfigFields';
|
||||
|
||||
interface Inst { id: string; connector: string; name: string; enabled: boolean; syncIntervalSec: number; health: string; healthMessage: string | null; capabilities: string[]; lastOkAt: string | null; lastError?: { tech: string; hint: string } | null; lastErrorAt?: string | null; lastSyncAt: string | null; hasSecrets: boolean; resources: number; config?: Record<string, unknown> }
|
||||
interface Type { key: string; name: string; fields: CfgField[] }
|
||||
const HEALTH: Record<string, { cls: string; icon: string; label: string }> = { ok: { cls: 'ok', icon: '●', label: 'Erreichbar' }, down: { cls: 'err', icon: '✕', label: 'Gestört' }, unknown: { cls: 'info', icon: '○', label: 'Noch nicht geprüft' } };
|
||||
export default function Connectors() {
|
||||
const { can } = useSession(); const w = can('connectors.write');
|
||||
const [list, setList] = useState<Inst[] | null>(null); const [types, setTypes] = useState<Type[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [open, setOpen] = useState(false); const [sel, setSel] = useState(''); const [editing, setEditing] = useState<Inst | null>(null);
|
||||
const load = useCallback(() => api<Inst[]>('GET', '/admin/connectors').then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
|
||||
useEffect(() => { void load(); api<Type[]>('GET', '/admin/connector-types').then((t) => { setTypes(t); setSel(t[0]?.key ?? ''); }).catch(() => undefined); const t = setInterval(() => void load(), 10000); return () => clearInterval(t); }, [load]);
|
||||
const type = types.find((t) => t.key === sel);
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget); const values: Record<string, string> = {};
|
||||
for (const fl of type?.fields ?? []) { const v = f.get(fl.name) as string; if (v) values[fl.name] = v; }
|
||||
try { await api('POST', '/admin/connectors', { connector: sel, name: f.get('name'), values }); setOpen(false); setMsg({ k: 'ok', t: 'Verbindung angelegt. Der erste Abgleich läuft im Hintergrund.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function saveEdit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); if (!editing) return; const f = new FormData(e.currentTarget); const values: Record<string, string> = {};
|
||||
for (const fl of types.find((t) => t.key === editing.connector)?.fields ?? []) { const v = (f.get(fl.name) as string)?.trim(); if (v) values[fl.name] = v; }
|
||||
try { await api('PATCH', `/admin/connectors/${editing.id}`, { values }); await api('POST', `/admin/connectors/${editing.id}/sync`); setEditing(null); setMsg({ k: 'ok', t: 'Gespeichert. Ein Abgleich wurde eingeplant.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function sync(i: Inst) { try { await api('POST', `/admin/connectors/${i.id}/sync`); setMsg({ k: 'ok', t: `Abgleich für „${i.name}“ eingeplant.` }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
async function toggle(i: Inst) { try { await api('PATCH', `/admin/connectors/${i.id}`, { enabled: !i.enabled }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
return (<>
|
||||
<div className="row between"><h2>Verbindungen</h2>{w && <button className="btn primary" aria-expanded={open} onClick={() => setOpen(!open)}>Verbindung anlegen</button>}</div>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{open && type && <form className="card" onSubmit={create}><h2>Neue Verbindung</h2>
|
||||
{sel === 'keyhelp' && <Alert kind="info"><strong>KeyHelp einrichten:</strong> Im KeyHelp-Adminbereich unter <em>Konfiguration → API</em> die API aktivieren und einen Schlüssel anlegen. Den Schlüssel <strong>auf die IP-Adresse dieses Servers beschränken</strong> (er hat Administratorrechte) und hier eintragen. Die KeyHelp-Version wird automatisch erkannt. Bei einem selbstsignierten Zertifikat den SHA-256-Fingerabdruck angeben (sicherer als die Prüfung abzuschalten). Der Abgleich arbeitet zunächst nur lesend.</Alert>}
|
||||
<div className="cols"><Field id="type" label="Typ"><select id="type" value={sel} onChange={(e) => setSel(e.target.value)}>{types.map((t) => <option key={t.key} value={t.key}>{t.name}</option>)}</select></Field>
|
||||
<Field id="name" label="Name"><input id="name" name="name" required /></Field></div>
|
||||
<ConfigFields fields={type.fields} prefix="f-" />
|
||||
<div className="row"><button className="btn primary" type="submit">Anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <div className="card"><Empty title="Noch keine Verbindungen">{w ? 'Legen Sie die erste Verbindung an, z. B. zum Lizenzsystem.' : 'Ein Superadministrator kann Verbindungen einrichten.'}</Empty></div> :
|
||||
list.map((i) => { const h = HEALTH[i.health] ?? HEALTH.unknown!; return (
|
||||
<div className="card" key={i.id}><div className="row between"><h2 style={{ margin: 0 }}>{i.name}</h2><span className={`badge ${h.cls}`}><span aria-hidden="true">{h.icon}</span>{h.label}</span></div>
|
||||
<p className="muted small">Typ: {types.find((t) => t.key === i.connector)?.name ?? i.connector} · {i.resources} Ressourcen · Abgleich alle {Math.round(i.syncIntervalSec / 60)} Min. {!i.enabled && '· deaktiviert'}</p>
|
||||
{i.health === 'down' && <Alert kind="err">{i.healthMessage ?? 'Der Dienst ist nicht erreichbar.'} Zuletzt erreichbar: {i.lastOkAt ? fmt(i.lastOkAt) : 'nie'}. Kunden sehen bis dahin den zuletzt bekannten Stand.{i.lastError && <><br /><strong>Mögliche Ursache:</strong> {i.lastError.hint || 'Keine Einschätzung möglich.'}<details style={{ marginTop: 6 }}><summary style={{ cursor: 'pointer' }}>Technische Details</summary><code>{i.lastError.tech || '–'}</code>{i.lastErrorAt && <span className="muted small"> (Stand {fmt(i.lastErrorAt)})</span>}</details></>}</Alert>}
|
||||
<p className="small">Letzter erfolgreicher Abgleich: {i.lastOkAt ? fmt(i.lastOkAt) : '–'} · Letzter Versuch: {i.lastSyncAt ? fmt(i.lastSyncAt) : '–'}</p>
|
||||
<p className="small muted">Fähigkeiten: {i.capabilities.length ? i.capabilities.join(', ') : '–'}</p>
|
||||
{editing?.id === i.id && <form onSubmit={saveEdit} style={{ margin: '16px 0' }}><p className="small muted">Leere Felder bleiben unverändert. Gespeicherte Zugangsdaten werden aus Sicherheitsgründen nie angezeigt.</p>
|
||||
<ConfigFields fields={types.find((t) => t.key === i.connector)?.fields ?? []} prefix="e-" existing={i.config as Record<string, unknown>} hasSecrets={i.hasSecrets} />
|
||||
<div className="row"><button className="btn primary" type="submit">Speichern</button><button className="btn" type="button" onClick={() => setEditing(null)}>Abbrechen</button></div></form>}
|
||||
<div className="row"><button className="btn small" onClick={() => sync(i)}>Jetzt abgleichen</button>{w && <button className="btn small" onClick={() => setEditing(editing?.id === i.id ? null : i)} aria-expanded={editing?.id === i.id}>Zugangsdaten / Einstellungen ändern</button>}{w && <button className="btn small" onClick={() => toggle(i)}>{i.enabled ? 'Deaktivieren' : 'Aktivieren'}</button>}</div></div>); })}
|
||||
</>);
|
||||
}
|
||||
77
apps/web/src/app/(app)/konto/page.tsx
Normal file
77
apps/web/src/app/(app)/konto/page.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Field, fmt } from '@/components/ui';
|
||||
|
||||
interface Sess { id: string; ip: string | null; userAgent: string | null; lastSeenAt: string; current: boolean }
|
||||
export default function Konto() {
|
||||
const { me, reload } = useSession();
|
||||
const [sessions, setSessions] = useState<Sess[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [setup, setSetup] = useState<{ secret: string; url: string; qr: string } | null>(null); const [codes, setCodes] = useState<string[] | null>(null);
|
||||
const load = useCallback(() => api<Sess[]>('GET', '/auth/sessions').then(setSessions).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
|
||||
useEffect(() => { if (me && !me.mfaEnrollRequired) void load(); }, [me, load]);
|
||||
if (!me) return null;
|
||||
|
||||
async function startMfa() {
|
||||
try { const s = await api<{ secret: string; otpauthUrl: string }>('POST', '/auth/mfa/setup'); setSetup({ secret: s.secret, url: s.otpauthUrl, qr: await QRCode.toDataURL(s.otpauthUrl, { margin: 1, width: 220 }) }); }
|
||||
catch (e) { setMsg({ k: 'err', t: errMsg(e) }); }
|
||||
}
|
||||
async function confirmMfa(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
try { const r = await api<{ recoveryCodes: string[] }>('POST', '/auth/mfa/confirm', { code: new FormData(e.currentTarget).get('code') }); setCodes(r.recoveryCodes); setSetup(null); await reload(); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function disableMfa(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget);
|
||||
try { await api('POST', '/auth/mfa/disable', { password: f.get('password'), code: f.get('code') }); setResetOpen(false); setCodes(null); await reload(); setMsg({ k: 'ok', t: 'Die 2FA wurde entfernt. Sie können sie jetzt mit dem neuen Gerät neu einrichten.' }); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function changePw(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const form = e.currentTarget; const f = new FormData(form);
|
||||
if (f.get('next') !== f.get('repeat')) { setMsg({ k: 'err', t: 'Die neuen Passwörter stimmen nicht überein. Bitte prüfen Sie Ihre Eingabe.' }); return; }
|
||||
try { await api('POST', '/auth/password/change', { current: f.get('current'), next: f.get('next'), repeat: f.get('repeat') }); form.reset(); setMsg({ k: 'ok', t: 'Passwort geändert. Andere Sitzungen wurden abgemeldet.' }); void load(); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
async function revoke(id: string) { try { await api('DELETE', `/auth/sessions/${id}`); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
|
||||
return (<>
|
||||
<h1>Mein Konto</h1>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{me.mfaEnrollRequired && <Alert kind="warn">Für Mitarbeiterkonten ist die Zwei-Faktor-Anmeldung verpflichtend. Bitte richten Sie sie jetzt ein, um fortzufahren.</Alert>}
|
||||
<div className="card"><h2>Zwei-Faktor-Anmeldung</h2>
|
||||
{codes ? (<><Alert kind="ok">2FA ist aktiv. Bewahren Sie diese Wiederherstellungscodes sicher auf. Sie werden nur jetzt angezeigt.</Alert><div className="codes mono">{codes.map((c) => <div key={c}>{c}</div>)}</div></>)
|
||||
: me.mfaEnrolled ? (<>
|
||||
<p><span className="badge ok"><span aria-hidden="true">●</span>Aktiv</span> Ihr Konto ist mit einer Authenticator-App geschützt.</p>
|
||||
{resetOpen ? (<form onSubmit={disableMfa}>
|
||||
<p><strong>2FA entfernen und mit neuem Gerät einrichten</strong><br /><span className="muted small">Zur Sicherheit benötigen wir Ihr Passwort und einen aktuellen Code (oder einen Wiederherstellungscode). Andere angemeldete Sitzungen werden beendet.</span></p>
|
||||
<div className="cols"><Field id="mpw" label="Passwort"><input id="mpw" name="password" type="password" autoComplete="current-password" required /></Field>
|
||||
<Field id="mcode" label="Code oder Wiederherstellungscode"><input id="mcode" name="code" autoComplete="one-time-code" required /></Field></div>
|
||||
<div className="row"><button className="btn danger" type="submit">2FA entfernen</button><button className="btn" type="button" onClick={() => setResetOpen(false)}>Abbrechen</button></div>
|
||||
<p className="small muted" style={{ marginTop: 12 }}>Haben Sie weder Handy noch Wiederherstellungscodes mehr? Dann kann der Support Ihre 2FA zurücksetzen.</p></form>)
|
||||
: <button className="btn" onClick={() => setResetOpen(true)}>Neues Handy? 2FA zurücksetzen</button>}
|
||||
</>)
|
||||
: setup ? (<form onSubmit={confirmMfa}>
|
||||
<p>Scannen Sie den QR-Code mit einer Authenticator-App und geben Sie den angezeigten Code ein.</p>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={setup.qr} width={220} height={220} alt="QR-Code für die Authenticator-App" />
|
||||
<p className="small muted">Oder Schlüssel manuell eingeben: <code>{setup.secret}</code></p>
|
||||
<Field id="code" label="6-stelliger Code"><input id="code" name="code" inputMode="numeric" pattern="\d{6}" autoComplete="one-time-code" required /></Field>
|
||||
<button className="btn primary" type="submit">Aktivieren</button></form>)
|
||||
: <><p>Schützen Sie Ihr Konto zusätzlich mit einem Code aus einer Authenticator-App.</p><button className="btn primary" onClick={startMfa}>2FA einrichten</button></>}
|
||||
</div>
|
||||
{!me.mfaEnrollRequired && (<>
|
||||
<div className="card"><h2>Passwort ändern</h2><form onSubmit={changePw}>
|
||||
<div className="cols"><Field id="current" label="Aktuelles Passwort"><input id="current" name="current" type="password" autoComplete="current-password" required /></Field>
|
||||
<Field id="next" label="Neues Passwort" hint="Mindestens 12 Zeichen."><input id="next" name="next" type="password" autoComplete="new-password" minLength={12} required /></Field>
|
||||
<Field id="repeat" label="Neues Passwort wiederholen"><input id="repeat" name="repeat" type="password" autoComplete="new-password" minLength={12} required /></Field></div>
|
||||
<button className="btn" type="submit">Speichern</button></form></div>
|
||||
<div className="card"><h2>Aktive Sitzungen</h2>
|
||||
<div className="tablewrap"><table><thead><tr><th>Gerät</th><th>IP</th><th>Zuletzt aktiv</th><th><span className="sr">Aktion</span></th></tr></thead><tbody>
|
||||
{sessions.map((s) => <tr key={s.id}><td>{(s.userAgent ?? 'Unbekannt').slice(0, 60)} {s.current && <span className="badge info">Diese Sitzung</span>}</td><td>{s.ip}</td><td>{fmt(s.lastSeenAt)}</td>
|
||||
<td>{!s.current && <button className="btn small danger" onClick={() => revoke(s.id)}>Beenden</button>}</td></tr>)}
|
||||
</tbody></table></div></div></>)}
|
||||
</>);
|
||||
}
|
||||
47
apps/web/src/app/(app)/layout.tsx
Normal file
47
apps/web/src/app/(app)/layout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use client';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { ROLE_LABEL } from '@/components/ui';
|
||||
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
const { me, loading, can, reload } = useSession(); const r = useRouter(); const path = usePathname(); const [open, setOpen] = useState(false);
|
||||
const enrollNeeded = !!me?.mfaEnrollRequired;
|
||||
useEffect(() => { if (!loading && (!me || me.pendingMfa)) r.replace('/login'); }, [me, loading, r]);
|
||||
useEffect(() => { if (enrollNeeded && path !== '/konto') r.replace('/konto'); }, [enrollNeeded, path, r]);
|
||||
useEffect(() => setOpen(false), [path]);
|
||||
if (loading || !me || me.pendingMfa) return <div className="center"><p className="muted" role="status">Wird geladen …</p></div>;
|
||||
const link = (href: string, label: string) => <Link href={href} aria-current={path === href || path.startsWith(href + '/') ? 'page' : undefined}>{label}</Link>;
|
||||
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
|
||||
const nav = (
|
||||
<nav className={`nav ${open ? 'open' : ''}`} aria-label="Hauptnavigation">
|
||||
<div className="brand">Kundencenter</div>
|
||||
{!enrollNeeded && link('/dashboard', 'Übersicht')}
|
||||
{!enrollNeeded && (me.kind === 'customer' || can('resources.read')) && link('/ressourcen', 'Ressourcen')}
|
||||
{!enrollNeeded && (me.kind === 'customer' || can('contracts.read')) && link('/vertraege', 'Verträge')}
|
||||
{!enrollNeeded && (me.kind === 'customer' || can('orders.read')) && link('/bestellungen', 'Bestellungen')}
|
||||
{!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')}
|
||||
{!enrollNeeded && link('/domains', 'Domain prüfen')}
|
||||
{!enrollNeeded && me.kind === 'staff' && <>
|
||||
<div className="group">Verwaltung</div>
|
||||
{can('customers.read') && link('/admin/kunden', 'Kunden')}
|
||||
{can('products.read') && link('/admin/produkte', 'Produkte')}
|
||||
{can('domains.read') && link('/admin/domains', 'Domain-Aufstellung')}
|
||||
{can('users.read') && link('/admin/benutzer', 'Benutzer')}
|
||||
{can('jobs.read') && link('/admin/auftraege', 'Aufträge')}
|
||||
{can('audit.read') && link('/admin/audit', 'Audit-Protokoll')}
|
||||
{(can('connectors.read') || can('backup.read')) && link('/einstellungen', 'Einstellungen')}
|
||||
</>}
|
||||
<div className="spacer" />
|
||||
{link('/konto', 'Mein Konto')}
|
||||
<div className="small muted" style={{ padding: '8px 16px' }}>{me.user.name}<br />{me.staffRole ? ROLE_LABEL[me.staffRole] : 'Kunde'}</div>
|
||||
<button className="btn small" onClick={logout}>Abmelden</button>
|
||||
</nav>
|
||||
);
|
||||
return (<div className="shell">
|
||||
<div className="topbar"><strong>Kundencenter</strong><button className="btn small" aria-expanded={open} onClick={() => setOpen(!open)}>Menü</button></div>
|
||||
{nav}<main id="main" className="main">{children}</main>
|
||||
</div>);
|
||||
}
|
||||
35
apps/web/src/app/(app)/organisation/page.tsx
Normal file
35
apps/web/src/app/(app)/organisation/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Field, ROLE_LABEL, Status } from '@/components/ui';
|
||||
|
||||
interface Org { id: string; name: string; customerNumber: string; customerType: 'private' | 'business'; status: string; billing: Record<string, string | null>; members: { id: string; email: string; name: string; status: string; role: string }[] }
|
||||
export default function Organisation() {
|
||||
const { me } = useSession(); const org0 = me?.organizations[0];
|
||||
const [org, setOrg] = useState<Org | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null);
|
||||
const load = useCallback(() => org0 && api<Org>('GET', `/orgs/${org0.id}`).then(setOrg).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [org0]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
if (!org0) return <p>Ihrem Konto ist keine Organisation zugeordnet.</p>;
|
||||
const canInvite = org0.role === 'owner' || org0.role === 'admin';
|
||||
async function invite(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const form = e.currentTarget; const f = new FormData(form);
|
||||
try { const r = await api<{ mail: string; inviteLink?: string }>('POST', `/orgs/${org0!.id}/invitations`, { email: f.get('email'), name: f.get('name'), role: f.get('role') });
|
||||
form.reset(); setMsg(r.mail === 'sent' ? { k: 'ok', t: 'Einladung wurde per E-Mail versendet.' } : { k: 'warn', t: `E-Mail-Versand ist nicht eingerichtet. Einladungslink (einmalig anzeigen): ${r.inviteLink}` }); void load(); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
return (<>
|
||||
<h1>{org?.name ?? org0.name}</h1><p className="muted">Kundennummer {org0.customerNumber}</p>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{org && <div className="card"><h2>Rechnungsanschrift</h2>
|
||||
<p>{[org.customerType === 'business' ? (org.billing.company ?? org.name) : org.name, org.billing.street, [org.billing.zip, org.billing.city].filter(Boolean).join(' '), org.billing.country].filter(Boolean).join(', ') || <span className="muted">Keine Anschrift hinterlegt.</span>}</p>
|
||||
<p className="small muted">{org.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}{org.billing.vatId ? ` · USt-IdNr. ${org.billing.vatId}` : ''} · Rechnungs-E-Mail: {org.billing.billingEmail ?? '–'}</p></div>}
|
||||
<div className="card"><h2>Mitglieder</h2>
|
||||
<div className="tablewrap"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr></thead><tbody>
|
||||
{org?.members.map((m) => <tr key={m.id}><td>{m.name}</td><td>{m.email}</td><td>{ROLE_LABEL[m.role]}</td><td><Status value={m.status} /></td></tr>)}</tbody></table></div></div>
|
||||
{canInvite && <div className="card"><h2>Mitarbeiter einladen</h2><form onSubmit={invite}><div className="cols">
|
||||
<Field id="name" label="Name"><input id="name" name="name" required /></Field><Field id="email" label="E-Mail"><input id="email" name="email" type="email" required /></Field>
|
||||
<Field id="role" label="Rolle"><select id="role" name="role"><option value="member">Mitglied (nur lesen)</option><option value="admin">Administrator</option></select></Field></div>
|
||||
<button className="btn primary" type="submit">Einladung senden</button></form></div>}
|
||||
</>);
|
||||
}
|
||||
79
apps/web/src/app/(app)/ressourcen/[id]/page.tsx
Normal file
79
apps/web/src/app/(app)/ressourcen/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { ACTION_LABEL, Alert, Field, JobStatus, ResState, fmt, CUSTOMER_ACTIONS } from '@/components/ui';
|
||||
import { SecretField } from '@/components/SecretField';
|
||||
import { uuid } from '@/lib/uuid';
|
||||
import { HostingPanel, UsageBars } from '@/components/HostingPanel';
|
||||
|
||||
interface Det { id: string; type: string; name: string; state: string; orgId: string | null; validFrom: string | null; validUntil: string | null; syncedAt: string; stale: boolean; staleReason: string | null; missing: boolean; instance?: string; connector?: string;
|
||||
canReveal?: boolean; hasChildren?: boolean; canLogin?: boolean; limits: Record<string, unknown>; usage: Record<string, unknown>; details: Record<string, any>; allowedActions: string[]; customerActions?: string[]; jobs: { id: string; action: string; status: string; error: string | null; createdAt: string }[] }
|
||||
const LIMIT_LABEL: Record<string, string> = { users: 'Benutzer', storageMb: 'Speicher (MB)' };
|
||||
export default function Ressource() {
|
||||
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('resources.read'); const w = can('resources.write');
|
||||
const [d, setD] = useState<Det | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null);
|
||||
const [confirm, setConfirm] = useState<{ action: string; days?: number } | null>(null); const key = useRef(uuid());
|
||||
const [custs, setCusts] = useState<{ id: string; name: string; customerNumber: string }[]>([]);
|
||||
const load = useCallback(() => api<Det>('GET', `/resources/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
useEffect(() => { if (w) api<typeof custs>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [w]);
|
||||
// Laufende Aufträge regelmäßig aktualisieren
|
||||
const pending = d?.jobs.some((j) => ['scheduled', 'running', 'retrying', 'waiting_external'].includes(j.status));
|
||||
useEffect(() => { if (!pending) return; const t = setInterval(() => void load(), 3000); return () => clearInterval(t); }, [pending, load]);
|
||||
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||
|
||||
async function run(a: { action: string; days?: number }) {
|
||||
try { await fetchAction(a); setConfirm(null); key.current = uuid(); setMsg({ k: 'ok', t: 'Der Auftrag wurde angenommen und wird im Hintergrund ausgeführt.' }); void load(); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
// Idempotency-Key pro Bestätigungsdialog: Doppelklicks erzeugen keinen zweiten Auftrag
|
||||
async function fetchAction(a: { action: string; days?: number }) {
|
||||
const csrf = (await api<{ csrf: string }>('GET', '/auth/me')).csrf;
|
||||
const res = await fetch(`/api/resources/${id}/actions`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-csrf-token': csrf, 'idempotency-key': key.current }, body: JSON.stringify(a) });
|
||||
if (!res.ok) { const j = await res.json().catch(() => null); throw new Error(j?.error?.message ?? 'Anfrage fehlgeschlagen'); }
|
||||
}
|
||||
async function assign(orgId: string) { try { await api('PATCH', `/admin/resources/${id}`, { orgId: orgId || null }); setMsg({ k: 'ok', t: 'Zuordnung gespeichert.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
async function setActions(list: string[]) { try { await api('PATCH', `/admin/resources/${id}`, { customerActions: list }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
const act = d.allowedActions;
|
||||
return (<>
|
||||
<p><Link href="/ressourcen">← Alle Ressourcen</Link></p>
|
||||
<div className="row between"><h1>{d.name}</h1><ResState value={d.state} /></div>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{d.stale && <Alert kind="warn"><strong>Angezeigt wird der zuletzt bekannte Stand vom {fmt(d.syncedAt)}.</strong> {d.staleReason} Änderungen sind währenddessen nicht möglich.</Alert>}
|
||||
{d.missing && <Alert kind="warn">Diese Ressource wurde beim letzten Abgleich nicht mehr beim Anbieter gefunden.</Alert>}
|
||||
<div className="card"><h2>Details</h2>
|
||||
<dl className="cols" style={{ margin: 0 }}>
|
||||
<div><dt className="muted small">Gültig ab</dt><dd style={{ margin: 0 }}>{d.validFrom ? fmt(d.validFrom) : '–'}</dd></div>
|
||||
<div><dt className="muted small">Gültig bis</dt><dd style={{ margin: 0 }}>{d.validUntil ? fmt(d.validUntil) : 'unbefristet'}</dd></div>
|
||||
{d.type !== 'hosting_account' && Object.entries(d.limits).map(([k, v]) => <div key={k}><dt className="muted small">{LIMIT_LABEL[k] ?? k}</dt><dd style={{ margin: 0 }}>{v === null ? 'unbegrenzt' : String(v)}{d.usage[k] !== undefined ? ` (genutzt: ${String(d.usage[k])})` : ''}</dd></div>)}
|
||||
{d.details.edition && <div><dt className="muted small">Edition</dt><dd style={{ margin: 0 }}>{String(d.details.edition)}</dd></div>}
|
||||
{d.details.program && <div><dt className="muted small">Programm</dt><dd style={{ margin: 0 }}>{String(d.details.program)}</dd></div>}
|
||||
{d.details.licenseKeyMasked && <div><dt className="muted small">Lizenzschlüssel</dt><dd className="mono" style={{ margin: 0 }}>{String(d.details.licenseKeyMasked)}</dd></div>}
|
||||
{d.details.activation ? <>
|
||||
<div><dt className="muted small">Aktiviert am</dt><dd style={{ margin: 0 }}>{fmt(d.details.activation.activatedAt)}</dd></div>
|
||||
<div><dt className="muted small">Letzte Prüfung</dt><dd style={{ margin: 0 }}>{fmt(d.details.activation.lastCheckAt)}</dd></div>
|
||||
<div><dt className="muted small">Gerät</dt><dd className="mono" style={{ margin: 0 }}>{d.details.activation.hardwareIdMasked}</dd></div></> : d.type === 'license' ? <div><dt className="muted small">Aktivierung</dt><dd style={{ margin: 0 }}>Noch nicht aktiviert</dd></div> : null}
|
||||
</dl>
|
||||
<p className="small muted" style={{ marginTop: 16 }}>Stand: {fmt(d.syncedAt)}{staff && d.instance ? ` · Verbindung: ${d.instance}` : ''}</p></div>
|
||||
{d.type === 'hosting_account' && <UsageBars usage={d.usage} limits={d.limits} />}
|
||||
{(d.hasChildren || d.canLogin) && <HostingPanel resourceId={d.id} canLogin={!!d.canLogin} />}
|
||||
{d.canReveal && <div className="card"><h2>Lizenzschlüssel</h2><SecretField resourceId={d.id} /></div>}
|
||||
<div className="card"><h2>Aktionen</h2>
|
||||
{act.length === 0 ? <p className="muted">Für diese Ressource sind aktuell keine Aktionen verfügbar.</p> : confirm ? (
|
||||
<div role="alertdialog" aria-labelledby="cd"><p id="cd"><strong>{ACTION_LABEL[confirm.action]}{confirm.days ? ` um ${confirm.days} Tage` : ''}?</strong> Die Änderung wird als Auftrag ausgeführt und protokolliert.</p>
|
||||
<div className="row"><button className="btn primary" onClick={() => run(confirm)}>Ja, ausführen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>
|
||||
) : <div className="row">{act.includes('suspend') && d.state !== 'suspended' && <button className="btn danger" onClick={() => setConfirm({ action: 'suspend' })}>Sperren</button>}
|
||||
{act.includes('unsuspend') && d.state === 'suspended' && <button className="btn" onClick={() => setConfirm({ action: 'unsuspend' })}>Entsperren</button>}
|
||||
{act.includes('extend') && [30, 365].map((n) => <button className="btn" key={n} onClick={() => setConfirm({ action: 'extend', days: n })}>Um {n} Tage verlängern</button>)}</div>}
|
||||
</div>
|
||||
{d.jobs.length > 0 && <div className="card"><h2>Letzte Aufträge</h2><div className="tablewrap"><table><thead><tr><th>Aktion</th><th>Status</th><th>Angelegt</th></tr></thead><tbody>
|
||||
{d.jobs.map((j) => <tr key={j.id}><td>{ACTION_LABEL[j.action] ?? j.action}</td><td><JobStatus value={j.status} />{j.error && j.status !== 'succeeded' && <div className="small muted">{j.error}</div>}</td><td>{fmt(j.createdAt)}</td></tr>)}</tbody></table></div></div>}
|
||||
{w && <div className="card"><h2>Verwaltung</h2>
|
||||
<Field id="org" label="Zugeordneter Kunde"><select id="org" value={d.orgId ?? ''} onChange={(e) => assign(e.target.value)}><option value="">— nicht zugewiesen —</option>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>
|
||||
<fieldset style={{ border: 0, padding: 0, margin: 0 }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Diese Aktionen darf der Kunde selbst ausführen</legend>
|
||||
<div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={!!d.customerActions?.includes(a)} onChange={(e) => setActions(e.target.checked ? [...(d.customerActions ?? []), a] : (d.customerActions ?? []).filter((x) => x !== a))} />{ACTION_LABEL[a]}</label>)}</div></fieldset></div>}
|
||||
</>);
|
||||
}
|
||||
63
apps/web/src/app/(app)/ressourcen/page.tsx
Normal file
63
apps/web/src/app/(app)/ressourcen/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use client';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, ResState, fmt } from '@/components/ui';
|
||||
import { SecretField } from '@/components/SecretField';
|
||||
|
||||
interface R { id: string; type: string; name: string; state: string; validUntil: string | null; syncedAt: string; stale: boolean; staleReason: string | null; orgId: string | null; instance?: string; canReveal?: boolean }
|
||||
const TYPE: Record<string, string> = { license: 'Lizenz', hosting_account: 'Hosting-Konto', domain: 'Domain', server: 'Server' };
|
||||
const GROUPS = [{ key: 'hosting', title: 'Webhosting', types: ['hosting_account', 'domain', 'server'] }, { key: 'license', title: 'Lizenzen', types: ['license'] }];
|
||||
interface Cust { id: string; name: string; customerNumber: string }
|
||||
const NEW = '__new__';
|
||||
type Created = { id: string; customerNumber: string; mail: string; inviteLink?: string };
|
||||
/** Zuweisung eines Kunden an eine noch nicht zugeordnete Ressource (nur Personal mit Schreibrecht); Kunde kann direkt neu angelegt werden. */
|
||||
function AssignCell({ r, custs, onDone, onError, onCreated }: { r: R; custs: Cust[]; onDone: () => void; onError: (m: string) => void; onCreated: (c: Cust, r: Created) => void }) {
|
||||
const [org, setOrg] = useState(''); const [busy, setBusy] = useState(false); const [priv, setPriv] = useState(false);
|
||||
async function assign() { if (!org || org === NEW) return; setBusy(true); try { await api('PATCH', `/admin/resources/${r.id}`, { orgId: org }); onDone(); } catch (e) { onError(errMsg(e)); } finally { setBusy(false); } }
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); e.stopPropagation(); const f = new FormData(e.currentTarget); const v = (k: string) => String(f.get(k) ?? '').trim(); setBusy(true);
|
||||
try {
|
||||
const name = priv ? `${v('first')} ${v('last')}`.trim() : v('name');
|
||||
const c = await api<Created>('POST', '/admin/customers', { type: priv ? 'private' : 'business', name, owner: priv ? { email: v('email') } : { name: v('owner'), email: v('email') }, billing: { country: 'DE' } });
|
||||
onCreated({ id: c.id, name, customerNumber: c.customerNumber }, c); setOrg(c.id);
|
||||
} catch (x) { onError(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
return (<div>
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
|
||||
<select aria-label={`Kunde für ${r.name} auswählen`} value={org} onChange={(e) => setOrg(e.target.value)} style={{ minWidth: 200, minHeight: 36, padding: '4px 8px' }}><option value="">Kunde wählen …</option><option value={NEW}>+ Neuen Kunden anlegen …</option>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select>
|
||||
<button className="btn small primary" disabled={!org || org === NEW || busy} onClick={assign}>{busy ? '…' : 'Zuweisen'}</button></div>
|
||||
{org === NEW && <form onSubmit={create} style={{ marginTop: 8, padding: 12, border: '1px solid var(--border)', borderRadius: 8, maxWidth: 420 }}>
|
||||
<div className="row" style={{ marginBottom: 8 }}>{([[false, 'Geschäftskunde'], [true, 'Privatkunde']] as const).map(([v, l]) => <label key={l} className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={priv === v} onChange={() => setPriv(v)} /> {l}</label>)}</div>
|
||||
{priv ? <div className="cols"><Field id={`f-${r.id}`} label="Vorname"><input id={`f-${r.id}`} name="first" required autoFocus /></Field><Field id={`l-${r.id}`} label="Nachname"><input id={`l-${r.id}`} name="last" required /></Field></div>
|
||||
: <div className="cols"><Field id={`n-${r.id}`} label="Firmenname"><input id={`n-${r.id}`} name="name" required autoFocus /></Field><Field id={`o-${r.id}`} label="Ansprechpartner"><input id={`o-${r.id}`} name="owner" required /></Field></div>}
|
||||
<Field id={`e-${r.id}`} label="E-Mail (erhält die Einladung)"><input id={`e-${r.id}`} name="email" type="email" required /></Field>
|
||||
<div className="row"><button className="btn small primary" type="submit" disabled={busy}>Kunde anlegen</button><button className="btn small" type="button" onClick={() => setOrg('')}>Abbrechen</button></div>
|
||||
<p className="muted small">Adresse und weitere Angaben können danach unter Kunden ergänzt werden.</p></form>}
|
||||
</div>);
|
||||
}
|
||||
export default function Ressourcen() {
|
||||
const { can } = useSession(); const staff = can('resources.read'); const canAssign = can('resources.write');
|
||||
const [custs, setCusts] = useState<Cust[]>([]); const [ok, setOk] = useState('');
|
||||
const [list, setList] = useState<R[] | null>(null); const [err, setErr] = useState(''); const [unassigned, setUnassigned] = useState(false);
|
||||
const reload = () => api<R[]>('GET', `/resources${unassigned ? '?unassigned=1' : ''}`).then(setList).catch((e) => setErr(errMsg(e)));
|
||||
useEffect(() => { void reload(); }, [unassigned]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { if (canAssign) api<Cust[]>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [canAssign]);
|
||||
const stale = list?.find((r) => r.stale);
|
||||
return (<>
|
||||
<h1>Ressourcen</h1>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{ok && <Alert kind="ok">{ok}</Alert>}
|
||||
{stale && <Alert kind="warn">Einige Angaben sind möglicherweise nicht aktuell: {stale.staleReason} Angezeigt wird der zuletzt bekannte Stand.</Alert>}
|
||||
{staff && <label className="row small" style={{ marginBottom: 16 }}><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={unassigned} onChange={(e) => setUnassigned(e.target.checked)} /> Nur nicht zugewiesene Ressourcen</label>}
|
||||
{list === null ? <div className="card"><p className="muted" role="status">Wird geladen …</p></div> : list.length === 0 ? <div className="card"><Empty title="Keine Ressourcen">{staff ? 'Sobald eine Verbindung eingerichtet und abgeglichen ist, erscheinen die Ressourcen hier.' : 'Für Ihre Organisation sind noch keine Ressourcen hinterlegt.'}</Empty></div> :
|
||||
GROUPS.map((g) => { const rows = list.filter((r) => g.types.includes(r.type)); if (rows.length === 0) return null; const lic = g.key === 'license'; return (
|
||||
<section key={g.key} aria-labelledby={`h-${g.key}`} style={{ marginBottom: 24 }}>
|
||||
<h2 id={`h-${g.key}`}>{g.title} <span className="muted small">({rows.length})</span></h2>
|
||||
<div className="card"><div className="tablewrap"><table><thead><tr><th>Name</th>{!lic && <th>Typ</th>}<th>Status</th><th>Gültig bis</th><th>Stand</th>{lic && <th>Lizenzschlüssel</th>}</tr></thead><tbody>
|
||||
{rows.map((r) => <tr key={r.id}><td><Link href={`/ressourcen/${r.id}`}>{r.name}</Link>{staff && !r.orgId && <> <span className="badge warn"><span aria-hidden="true">▲</span>Nicht zugewiesen</span></>}{canAssign && !r.orgId && <div style={{ marginTop: 8 }}><AssignCell r={r} custs={custs} onError={setErr} onCreated={(c, cr) => { setCusts((l) => [...l, c]); setErr(''); setOk(`Kunde ${cr.customerNumber} angelegt. ` + (cr.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : cr.inviteLink ? `E-Mail-Versand ist nicht eingerichtet. Einladungslink (nur jetzt sichtbar): ${cr.inviteLink}` : '') + ' Jetzt noch „Zuweisen“ klicken.'); }} onDone={() => { setErr(''); setOk(`„${r.name}“ wurde zugewiesen. Kunden dürfen daran zunächst keine Aktionen ausführen (im Produkt bzw. an der Ressource freigebbar).`); void reload(); }} /></div>}</td>{!lic && <td>{TYPE[r.type] ?? r.type}</td>}<td><ResState value={r.state} /></td>
|
||||
<td>{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</td><td className="small">{fmt(r.syncedAt)}{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</td>{lic && <td>{r.canReveal ? <SecretField resourceId={r.id} compact /> : <span className="muted small">–</span>}</td>}</tr>)}</tbody></table></div></div>
|
||||
</section>); })}
|
||||
</>);
|
||||
}
|
||||
41
apps/web/src/app/(app)/vertraege/[id]/page.tsx
Normal file
41
apps/web/src/app/(app)/vertraege/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, ContractStatusBadge, fmt, INTERVAL, type Price, PriceText } from '@/components/ui';
|
||||
|
||||
interface D { id: string; number: string; orgName: string; status: string; productName: string; startedAt: string | null; termEnd: string | null; renewal: string; renewalTermMonths: number; noticeDays: number; cancelRequestedAt: string | null; cancelEffectiveAt: string | null; cancelledAt: string | null; resourceId: string | null; canCancel: boolean;
|
||||
price: Price & { customerType: string; name: string; sku: string; version: number; terms: { termMonths: number } } }
|
||||
export default function Vertrag() {
|
||||
const { id } = useParams<{ id: string }>(); const { can } = useSession();
|
||||
const [d, setD] = useState<D | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null);
|
||||
const load = useCallback(() => api<D>('GET', `/contracts/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||
async function cancel(immediate: boolean) { try { const r = await api<{ cancelEffectiveAt: string }>('POST', `/contracts/${id}/cancel`, { immediate }); setConfirm(null); setMsg({ k: 'ok', t: `Die Kündigung wurde vorgemerkt und wird wirksam am ${fmt(r.cancelEffectiveAt)}.` }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
async function revoke() { try { await api('POST', `/contracts/${id}/cancel/revoke`); setMsg({ k: 'ok', t: 'Die Kündigung wurde zurückgenommen.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
const priv = d.price.customerType === 'private'; const pending = !!d.cancelEffectiveAt && ['active', 'suspended'].includes(d.status);
|
||||
return (<>
|
||||
<p><Link href="/vertraege">← Alle Verträge</Link></p>
|
||||
<div className="row between"><h1>{d.productName}</h1><ContractStatusBadge value={d.status} /></div><p className="muted"><span className="mono">{d.number}</span> · {d.orgName}</p>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{pending && <Alert kind="warn">Dieser Vertrag ist gekündigt und endet am <strong>{fmt(d.cancelEffectiveAt!)}</strong>.</Alert>}
|
||||
<div className="card"><h2>Konditionen</h2>
|
||||
<dl className="cols" style={{ margin: 0 }}>
|
||||
<div><dt className="muted small">Preis</dt><dd style={{ margin: 0 }}><PriceText p={d.price} priv={priv} /></dd></div>
|
||||
<div><dt className="muted small">Beginn</dt><dd style={{ margin: 0 }}>{d.startedAt ? fmt(d.startedAt) : '–'}</dd></div>
|
||||
<div><dt className="muted small">Laufzeit bis</dt><dd style={{ margin: 0 }}>{d.termEnd ? fmt(d.termEnd) : 'unbefristet'}</dd></div>
|
||||
<div><dt className="muted small">Verlängerung</dt><dd style={{ margin: 0 }}>{d.renewal === 'auto' ? `automatisch um ${d.renewalTermMonths} Monat(e)` : 'keine'}</dd></div>
|
||||
<div><dt className="muted small">Kündigungsfrist</dt><dd style={{ margin: 0 }}>{d.noticeDays} Tage</dd></div>
|
||||
<div><dt className="muted small">Abrechnung</dt><dd style={{ margin: 0 }}>{INTERVAL[d.price.interval]}</dd></div></dl>
|
||||
<p className="small muted" style={{ marginTop: 16 }}>Preisstand bei Bestellung (Produkt {d.price.sku}, Version {d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.</p>
|
||||
{d.resourceId && <p><Link href={`/ressourcen/${d.resourceId}`}>Zugehörige Ressource ansehen</Link></p>}</div>
|
||||
{(d.canCancel || pending) && <div className="card"><h2>Kündigung</h2>
|
||||
{confirm ? (<div role="alertdialog" aria-labelledby="cc"><p id="cc"><strong>{confirm === 'immediate' ? 'Vertrag sofort beenden?' : 'Vertrag kündigen?'}</strong> {confirm === 'immediate' ? 'Die Ressource wird gesperrt.' : 'Die Kündigung wird zum nächstmöglichen Termin wirksam; bis dahin läuft der Vertrag weiter.'} Die Aktion wird protokolliert.</p>
|
||||
<div className="row"><button className="btn danger" onClick={() => cancel(confirm === 'immediate')}>Ja, kündigen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>)
|
||||
: pending ? (new Date(d.cancelEffectiveAt!) > new Date() && <button className="btn" onClick={revoke}>Kündigung zurücknehmen</button>)
|
||||
: <div className="row"><button className="btn danger" onClick={() => setConfirm('cancel')}>Vertrag kündigen</button>{can('contracts.write') && <button className="btn danger" onClick={() => setConfirm('immediate')}>Sofort beenden (Personal)</button>}</div>}</div>}
|
||||
</>);
|
||||
}
|
||||
21
apps/web/src/app/(app)/vertraege/page.tsx
Normal file
21
apps/web/src/app/(app)/vertraege/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, ContractStatusBadge, Empty, Field, fmt, type Price, PriceText } from '@/components/ui';
|
||||
|
||||
interface C { id: string; number: string; orgName: string; status: string; productName: string; termEnd: string | null; cancelEffectiveAt: string | null; price: Price & { customerType: string } }
|
||||
export default function Vertraege() {
|
||||
const { can } = useSession(); const staff = can('contracts.read');
|
||||
const [list, setList] = useState<C[] | null>(null); const [status, setStatus] = useState(''); const [err, setErr] = useState('');
|
||||
useEffect(() => { api<C[]>('GET', `/contracts${status ? `?status=${status}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))); }, [status]);
|
||||
return (<>
|
||||
<h1>Verträge</h1>{err && <Alert kind="err">{err}</Alert>}
|
||||
<div className="card"><Field id="st" label="Status"><select id="st" value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option><option value="active">Aktiv</option><option value="pending">In Vorbereitung</option><option value="suspended">Gesperrt</option><option value="cancelled">Beendet</option></select></Field>
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Verträge">Verträge entstehen aus Bestellungen.</Empty> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Produkt</th>{staff && <th>Kunde</th>}<th>Preis</th><th>Laufzeit bis</th><th>Status</th></tr></thead><tbody>
|
||||
{list.map((c) => <tr key={c.id}><td className="mono"><Link href={`/vertraege/${c.id}`}>{c.number}</Link></td><td>{c.productName}</td>{staff && <td>{c.orgName}</td>}<td className="small"><PriceText p={c.price} priv={c.price.customerType === 'private'} /></td>
|
||||
<td>{c.termEnd ? fmt(c.termEnd) : 'unbefristet'}{c.cancelEffectiveAt && <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Gekündigt zum {fmt(c.cancelEffectiveAt)}</span></div>}</td><td><ContractStatusBadge value={c.status} /></td></tr>)}</tbody></table></div>}
|
||||
</div></>);
|
||||
}
|
||||
25
apps/web/src/app/einladung/page.tsx
Normal file
25
apps/web/src/app/einladung/page.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
import { Suspense, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Alert, Field } from '@/components/ui';
|
||||
|
||||
function Form() {
|
||||
const token = useSearchParams().get('token') ?? '';
|
||||
const [done, setDone] = useState(false); const [err, setErr] = useState('');
|
||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr('');
|
||||
const f = new FormData(e.currentTarget);
|
||||
if (f.get('password') !== f.get('repeat')) { setErr('Die Passwörter stimmen nicht überein.'); return; }
|
||||
try { await api('POST', '/auth/invite/accept', { token, password: f.get('password'), repeat: f.get('repeat') }); setDone(true); } catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
return <main id="main" className="center"><div className="auth card"><h1>Zugang einrichten</h1>
|
||||
{!token ? <Alert kind="err">Der Link ist unvollständig.</Alert> : done ? <><Alert kind="ok">Ihr Zugang ist eingerichtet.</Alert><Link className="btn primary" href="/login">Zur Anmeldung</Link></> : (
|
||||
<form onSubmit={submit}>{err && <Alert kind="err">{err}</Alert>}
|
||||
<Field id="password" label="Passwort" hint="Mindestens 12 Zeichen."><input id="password" name="password" type="password" autoComplete="new-password" minLength={12} required autoFocus /></Field>
|
||||
<Field id="repeat" label="Passwort wiederholen"><input id="repeat" name="repeat" type="password" autoComplete="new-password" minLength={12} required /></Field>
|
||||
<button className="btn primary" type="submit">Passwort festlegen</button></form>)}
|
||||
</div></main>;
|
||||
}
|
||||
export default function Page() { return <Suspense><Form /></Suspense>; }
|
||||
56
apps/web/src/app/globals.css
Normal file
56
apps/web/src/app/globals.css
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/* Designsystem v0: ruhige Flächen, viel Weißraum, eine Akzentfarbe. Light/Dark über prefers-color-scheme + data-theme. */
|
||||
:root {
|
||||
--bg: #f7f8fa; --surface: #ffffff; --surface-2: #f0f2f5; --border: #dfe3e8; --text: #16191d; --muted: #5b6470;
|
||||
--accent: #2456d6; --accent-contrast: #ffffff; --focus: #2456d6;
|
||||
--ok: #17703a; --ok-bg: #e4f5ea; --info: #1d5fa8; --info-bg: #e3effb; --warn: #8a5a00; --warn-bg: #fdf0d2; --err: #a52a2a; --err-bg: #fbe5e5; --lock: #4b5563; --lock-bg: #e8eaee;
|
||||
--radius: 12px; --s1: 4px; --s2: 8px; --s3: 16px; --s4: 24px; --s5: 40px;
|
||||
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) {
|
||||
--bg: #0f1216; --surface: #171b21; --surface-2: #1f242c; --border: #2b323c; --text: #eceff3; --muted: #9aa5b3; --accent: #7aa2ff; --accent-contrast: #0b1020; --focus: #9db9ff;
|
||||
--ok: #6fd39a; --ok-bg: #12301f; --info: #8bbcf2; --info-bg: #142a42; --warn: #f0c063; --warn-bg: #3a2c0b; --err: #f29b9b; --err-bg: #3d1717; --lock: #b6bfcb; --lock-bg: #262c35; } }
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #0f1216; --surface: #171b21; --surface-2: #1f242c; --border: #2b323c; --text: #eceff3; --muted: #9aa5b3; --accent: #7aa2ff; --accent-contrast: #0b1020; --focus: #9db9ff;
|
||||
--ok: #6fd39a; --ok-bg: #12301f; --info: #8bbcf2; --info-bg: #142a42; --warn: #f0c063; --warn-bg: #3a2c0b; --err: #f29b9b; --err-bg: #3d1717; --lock: #b6bfcb; --lock-bg: #262c35;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: light dark; }
|
||||
body { margin: 0; font: 16px/1.55 var(--font); background: var(--bg); color: var(--text); }
|
||||
h1 { font-size: 1.6rem; margin: 0 0 var(--s3); letter-spacing: -0.01em; } h2 { font-size: 1.15rem; margin: 0 0 var(--s3); } h3 { font-size: 1rem; margin: 0 0 var(--s2); }
|
||||
a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--muted); } .small { font-size: .875rem; }
|
||||
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
|
||||
.skip { position: absolute; left: -999px; } .skip:focus { left: var(--s3); top: var(--s3); background: var(--surface); padding: var(--s2) var(--s3); z-index: 10; }
|
||||
|
||||
.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
|
||||
.nav { background: var(--surface); border-right: 1px solid var(--border); padding: var(--s4) var(--s3); display: flex; flex-direction: column; gap: var(--s2); }
|
||||
.nav .brand { font-weight: 700; font-size: 1.1rem; padding: 0 var(--s3) var(--s3); }
|
||||
.nav a { display: block; padding: 10px var(--s3); border-radius: 8px; color: var(--text); text-decoration: none; }
|
||||
.nav a:hover { background: var(--surface-2); } .nav a[aria-current='page'] { background: var(--surface-2); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.nav .group { margin: var(--s3) var(--s3) 0; font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
||||
.nav .spacer { flex: 1; }
|
||||
.main { padding: var(--s5) var(--s4); max-width: 1100px; width: 100%; }
|
||||
.topbar { display: none; }
|
||||
@media (max-width: 800px) {
|
||||
.shell { grid-template-columns: 1fr; } .nav { display: none; } .nav.open { display: flex; position: fixed; inset: 0 30% 0 0; z-index: 5; box-shadow: 0 0 0 100vmax rgba(0,0,0,.4); overflow: auto; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; padding: var(--s3); background: var(--surface); border-bottom: 1px solid var(--border); }
|
||||
.main { padding: var(--s4) var(--s3); }
|
||||
}
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s4); margin-bottom: var(--s4); }
|
||||
.grid { display: grid; gap: var(--s4); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||
.stat { font-size: 2rem; font-weight: 700; line-height: 1.1; } .row { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; } .row.between { justify-content: space-between; }
|
||||
.btn { font: inherit; padding: 10px 18px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; min-height: 44px; }
|
||||
.btn:hover { background: var(--surface-2); } .btn.primary { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } .btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn.danger { color: var(--err); border-color: var(--err); } .btn:disabled { opacity: .55; cursor: not-allowed; } .btn.small { min-height: 36px; padding: 6px 12px; }
|
||||
.field { display: grid; gap: 6px; margin-bottom: var(--s3); } .field label { font-weight: 600; font-size: .9rem; }
|
||||
input, select { font: inherit; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); min-height: 44px; width: 100%; }
|
||||
.cols { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px var(--s3); border-bottom: 1px solid var(--border); vertical-align: top; } th { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.badge { display: inline-flex; gap: 6px; align-items: center; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
|
||||
.badge.ok { background: var(--ok-bg); color: var(--ok); } .badge.info { background: var(--info-bg); color: var(--info); } .badge.warn { background: var(--warn-bg); color: var(--warn); } .badge.err { background: var(--err-bg); color: var(--err); } .badge.lock { background: var(--lock-bg); color: var(--lock); }
|
||||
.alert { border-radius: 8px; padding: var(--s3); margin-bottom: var(--s3); border: 1px solid transparent; } .alert.err { background: var(--err-bg); color: var(--err); } .alert.ok { background: var(--ok-bg); color: var(--ok); } .alert.warn { background: var(--warn-bg); color: var(--warn); } .alert.info { background: var(--info-bg); color: var(--info); }
|
||||
.empty { text-align: center; padding: var(--s5) var(--s3); color: var(--muted); }
|
||||
.center { min-height: 100vh; display: grid; place-items: center; padding: var(--s3); } .auth { width: 100%; max-width: 420px; }
|
||||
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; word-break: break-all; } .codes { columns: 2; background: var(--surface-2); padding: var(--s3); border-radius: 8px; }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||||
9
apps/web/src/app/layout.tsx
Normal file
9
apps/web/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import './globals.css';
|
||||
import { SessionProvider } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: { default: 'Kundencenter', template: '%s · Kundencenter' }, robots: { index: false } };
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return <html lang="de"><body><a className="skip" href="#main">Zum Inhalt springen</a><SessionProvider>{children}</SessionProvider></body></html>;
|
||||
}
|
||||
45
apps/web/src/app/login/page.tsx
Normal file
45
apps/web/src/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg, setCsrf } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Field } from '@/components/ui';
|
||||
|
||||
export default function Login() {
|
||||
const r = useRouter(); const { me, reload } = useSession();
|
||||
const [step, setStep] = useState<'pw' | 'mfa'>('pw');
|
||||
const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
|
||||
useEffect(() => { if (me?.pendingMfa) setStep('mfa'); else if (me) r.replace('/dashboard'); }, [me, r]);
|
||||
|
||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); setBusy(true);
|
||||
const f = new FormData(e.currentTarget);
|
||||
try {
|
||||
if (step === 'pw') {
|
||||
const res = await api<{ status: string; csrf: string }>('POST', '/auth/login', { email: f.get('email'), password: f.get('password') });
|
||||
setCsrf(res.csrf);
|
||||
if (res.status === 'mfa_required') { await reload(); setStep('mfa'); } else { await reload(); r.replace('/dashboard'); }
|
||||
} else {
|
||||
await api('POST', '/auth/mfa/verify', { code: f.get('code') });
|
||||
await reload(); r.replace('/dashboard');
|
||||
}
|
||||
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<main id="main" className="center"><div className="auth card">
|
||||
<h1>{step === 'pw' ? 'Anmelden' : 'Zweiter Faktor'}</h1>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
<form onSubmit={submit}>
|
||||
{step === 'pw' ? (<>
|
||||
<Field id="email" label="E-Mail"><input id="email" name="email" type="email" autoComplete="username" required autoFocus /></Field>
|
||||
<Field id="password" label="Passwort"><input id="password" name="password" type="password" autoComplete="current-password" required /></Field>
|
||||
</>) : (
|
||||
<Field id="code" label="Code aus der Authenticator-App" hint="Alternativ ein Wiederherstellungscode."><input id="code" name="code" inputMode="text" autoComplete="one-time-code" required autoFocus /></Field>
|
||||
)}
|
||||
<button className="btn primary" disabled={busy} type="submit">{busy ? 'Bitte warten …' : step === 'pw' ? 'Anmelden' : 'Bestätigen'}</button>
|
||||
</form>
|
||||
{step === 'pw' && <p className="small" style={{ marginTop: 16 }}><Link href="/passwort-vergessen">Passwort vergessen?</Link></p>}
|
||||
</div></main>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/app/page.tsx
Normal file
9
apps/web/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSession } from '@/lib/session';
|
||||
export default function Home() {
|
||||
const { me, loading } = useSession(); const r = useRouter();
|
||||
useEffect(() => { if (!loading) r.replace(me && !me.pendingMfa ? '/dashboard' : '/login'); }, [me, loading, r]);
|
||||
return <div className="center"><p className="muted" role="status">Wird geladen …</p></div>;
|
||||
}
|
||||
25
apps/web/src/app/passwort-reset/page.tsx
Normal file
25
apps/web/src/app/passwort-reset/page.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
import { Suspense, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Alert, Field } from '@/components/ui';
|
||||
|
||||
function Form() {
|
||||
const token = useSearchParams().get('token') ?? '';
|
||||
const [done, setDone] = useState(false); const [err, setErr] = useState('');
|
||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr('');
|
||||
const f = new FormData(e.currentTarget);
|
||||
if (f.get('password') !== f.get('repeat')) { setErr('Die Passwörter stimmen nicht überein.'); return; }
|
||||
try { await api('POST', '/auth/password/reset', { token, password: f.get('password'), repeat: f.get('repeat') }); setDone(true); } catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
return <main id="main" className="center"><div className="auth card"><h1>Neues Passwort</h1>
|
||||
{!token ? <Alert kind="err">Der Link ist unvollständig.</Alert> : done ? <><Alert kind="ok">Das Passwort wurde geändert.</Alert><Link className="btn primary" href="/login">Zur Anmeldung</Link></> : (
|
||||
<form onSubmit={submit}>{err && <Alert kind="err">{err}</Alert>}
|
||||
<Field id="password" label="Passwort" hint="Mindestens 12 Zeichen."><input id="password" name="password" type="password" autoComplete="new-password" minLength={12} required autoFocus /></Field>
|
||||
<Field id="repeat" label="Passwort wiederholen"><input id="repeat" name="repeat" type="password" autoComplete="new-password" minLength={12} required /></Field>
|
||||
<button className="btn primary" type="submit">Passwort speichern</button></form>)}
|
||||
</div></main>;
|
||||
}
|
||||
export default function Page() { return <Suspense><Form /></Suspense>; }
|
||||
14
apps/web/src/app/passwort-vergessen/page.tsx
Normal file
14
apps/web/src/app/passwort-vergessen/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use client';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Alert, Field } from '@/components/ui';
|
||||
export default function Forgot() {
|
||||
const [done, setDone] = useState(false); const [err, setErr] = useState('');
|
||||
async function submit(e: FormEvent<HTMLFormElement>) { e.preventDefault(); setErr(''); try { await api('POST', '/auth/password/forgot', { email: new FormData(e.currentTarget).get('email') }); setDone(true); } catch (x) { setErr(errMsg(x)); } }
|
||||
return <main id="main" className="center"><div className="auth card"><h1>Passwort zurücksetzen</h1>
|
||||
{done ? <Alert kind="ok">Falls ein Konto zu dieser Adresse existiert, wurde eine E-Mail mit einem Link gesendet.</Alert> : <form onSubmit={submit}>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
<Field id="email" label="E-Mail"><input id="email" name="email" type="email" required autoFocus /></Field><button className="btn primary" type="submit">Link anfordern</button></form>}
|
||||
<p className="small" style={{ marginTop: 16 }}><Link href="/login">Zurück zur Anmeldung</Link></p></div></main>;
|
||||
}
|
||||
50
apps/web/src/components/BackupPassword.tsx
Normal file
50
apps/web/src/components/BackupPassword.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
'use client';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { copyText } from '@/lib/clipboard';
|
||||
import { Alert, Field, fmt } from '@/components/ui';
|
||||
|
||||
export interface EncryptionInfo { mode: 'password' | 'age' | 'none'; passwordSet: boolean; version: number; updatedAt: string | null; minLength: number }
|
||||
const ALPHABET = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_.!?#%+=';
|
||||
/** Zufallspasswort mit dem Zufallsgenerator des Browsers (24 Zeichen, ohne verwechselbare Zeichen). */
|
||||
function generate(len = 24): string { const b = new Uint32Array(len); crypto.getRandomValues(b); return Array.from(b, (n) => ALPHABET[n % ALPHABET.length]).join(''); }
|
||||
|
||||
export function BackupPassword({ info, canEdit, onChanged }: { info: EncryptionInfo; canEdit: boolean; onChanged: (autoBackup: boolean) => void }) {
|
||||
const [pw, setPw] = useState(''); const [rep, setRep] = useState(''); const [cur, setCur] = useState(''); const [noted, setNoted] = useState(false); const [autoBackup, setAutoBackup] = useState(true);
|
||||
const [show, setShow] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(false);
|
||||
const min = info.minLength; const ok = pw.length >= min;
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault(); setMsg(null);
|
||||
if (pw !== rep) { setMsg({ k: 'err', t: 'Die beiden Passwörter stimmen nicht überein. Bitte prüfen Sie Ihre Eingabe.' }); return; }
|
||||
setBusy(true);
|
||||
try { const r = await api<{ version: number }>('PUT', '/admin/backup/password', { password: pw, repeat: rep, currentPassword: cur });
|
||||
setMsg({ k: 'ok', t: `Das Backup-Passwort wurde gespeichert (Version ${r.version}).${autoBackup ? ' Eine neue Sicherung wird jetzt erstellt.' : ' Bis zur nächsten Sicherung gilt für neue Backups das neue Passwort.'}` });
|
||||
setPw(''); setRep(''); setCur(''); setNoted(false); setShow(false); onChanged(autoBackup); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<div className="card"><h3>Verschlüsselung</h3>
|
||||
<p>{info.passwordSet ? <span className="badge ok"><span aria-hidden="true">✓</span>Backup-Passwort gesetzt</span> : info.mode === 'age' ? <span className="badge warn"><span aria-hidden="true">▲</span>Schlüsseldatei (ohne eigenes Passwort)</span> : <span className="badge err"><span aria-hidden="true">✕</span>Nicht verschlüsselt eingerichtet</span>}</p>
|
||||
<p className="small muted">{info.passwordSet ? `Version ${info.version}, zuletzt geändert ${info.updatedAt ? fmt(info.updatedAt) : '–'}. ` : ''}Alle Sicherungen werden mit AES-256 verschlüsselt. {info.passwordSet ? 'Zum Entschlüsseln genügt das Passwort (Standardwerkzeug gpg).' : 'Legen Sie ein eigenes Passwort fest, dann brauchen Sie im Notfall nur dieses.'}</p>
|
||||
{!canEdit ? <p className="small muted">Das Passwort können nur Superadministratoren ändern.</p> : (
|
||||
<form onSubmit={submit} style={{ marginTop: 16 }}>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
<Alert kind="warn"><strong>Es gibt keine Möglichkeit, dieses Passwort wiederherzustellen.</strong> Ohne Passwort sind alle Sicherungen unlesbar. Bewahren Sie es sicher auf (Passwortmanager). Nach einer Änderung bleiben ältere Sicherungen mit dem <em>alten</em> Passwort verschlüsselt: bewahren Sie es auf, bis diese durch die Aufbewahrungsfrist verschwunden sind.</Alert>
|
||||
<div className="cols">
|
||||
<Field id="bp-new" label={info.passwordSet ? 'Neues Backup-Passwort' : 'Backup-Passwort'} hint={`Mindestens ${min} Zeichen (länger als 10).`}><input id="bp-new" type={show ? 'text' : 'password'} autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} required minLength={min} maxLength={200} /></Field>
|
||||
<Field id="bp-rep" label="Passwort wiederholen"><input id="bp-rep" type={show ? 'text' : 'password'} autoComplete="new-password" value={rep} onChange={(e) => setRep(e.target.value)} required minLength={min} maxLength={200} /></Field>
|
||||
</div>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<span className={`badge ${ok ? 'ok' : 'warn'}`}><span aria-hidden="true">{ok ? '✓' : '▲'}</span>{pw.length} Zeichen{ok ? '' : ` (noch ${min - pw.length} bis zum Minimum)`}</span>
|
||||
<button type="button" className="btn small" onClick={() => { const g = generate(); setPw(g); setRep(g); setShow(true); setCopied(false); }}>Sicheres Passwort erzeugen</button>
|
||||
<button type="button" className="btn small" onClick={() => setShow(!show)} aria-pressed={show}>{show ? 'Verbergen' : 'Anzeigen'}</button>
|
||||
{pw && <button type="button" className="btn small" onClick={async () => setCopied(await copyText(pw))}>{copied ? 'Kopiert ✓' : 'Kopieren'}</button>}
|
||||
</div>
|
||||
<Field id="bp-cur" label="Zur Bestätigung: Ihr Anmeldepasswort"><input id="bp-cur" type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} required maxLength={200} /></Field>
|
||||
<label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={noted} onChange={(e) => setNoted(e.target.checked)} />Ich habe das Passwort sicher notiert. Ohne Passwort kann ich keine Sicherung wiederherstellen.</label>
|
||||
<label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={autoBackup} onChange={(e) => setAutoBackup(e.target.checked)} />Danach direkt eine neue Sicherung erstellen</label>
|
||||
<div className="row" style={{ marginTop: 12 }}><button className="btn primary" type="submit" disabled={busy || !ok || !noted || !cur}>{busy ? 'Bitte warten …' : info.passwordSet ? 'Passwort ändern' : 'Passwort festlegen'}</button></div>
|
||||
</form>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/BackupTargets.tsx
Normal file
65
apps/web/src/components/BackupTargets.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
'use client';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Alert, Empty, Field, fmt } from '@/components/ui';
|
||||
|
||||
export interface TargetInfo { id: string; name: string; type: 'sftp' | 'ftp' | 'gdrive' | 'local'; path: string; enabled: boolean; config: Record<string, any>; hasSecret: boolean; hostKeyPinned: boolean; hostKeyFingerprint: string | null; lastTestAt: string | null; lastTestOk: boolean | null; lastTestError: string | null }
|
||||
const TYPE: Record<string, string> = { sftp: 'SFTP', ftp: 'FTP / FTPS', gdrive: 'Google Drive', local: 'Ordner / Netzlaufwerk' };
|
||||
const where = (t: TargetInfo) => (t.type === 'sftp' || t.type === 'ftp' ? `${t.config.user}@${t.config.host}:${t.config.port}/${t.path}` : t.type === 'gdrive' ? `Drive:/${t.path}` : t.path);
|
||||
|
||||
export function BackupTargets({ targets, canEdit, onChanged }: { targets: TargetInfo[]; canEdit: boolean; onChanged: () => void }) {
|
||||
const [open, setOpen] = useState(false); const [type, setType] = useState<TargetInfo['type']>('sftp'); const [auth, setAuth] = useState<'password' | 'key'>('password');
|
||||
const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [busy, setBusy] = useState(''); const [del, setDel] = useState<string | null>(null);
|
||||
async function test(id: string, name: string) {
|
||||
setBusy(id);
|
||||
try { const r = await api<{ ok: boolean; error: string | null; fingerprint: string | null; newHostKey: boolean }>('POST', `/admin/backup/targets/${id}/test`);
|
||||
setMsg(r.ok ? { k: 'ok', t: `„${name}“: Verbindung funktioniert, Schreiben und Löschen einer Testdatei war erfolgreich.${r.newHostKey && r.fingerprint ? ` Server-Schlüssel gemerkt (${r.fingerprint}). Bitte mit dem Fingerabdruck Ihres Servers abgleichen; bei einer Abweichung schlägt der Upload künftig fehl.` : ''}` } : { k: 'err', t: `„${name}“: ${r.error ?? 'Verbindung fehlgeschlagen.'}` });
|
||||
onChanged(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(''); }
|
||||
}
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setMsg(null); const f = new FormData(e.currentTarget); const v = (k: string) => (f.get(k) as string) ?? '';
|
||||
const base: Record<string, unknown> = { type, name: v('name'), path: v('path') };
|
||||
if (type === 'sftp') Object.assign(base, { host: v('host'), port: Number(v('port') || 22), user: v('user'), authType: auth, ...(auth === 'password' ? { password: v('password') } : { privateKey: v('privateKey'), keyPassphrase: v('keyPassphrase') || undefined }) });
|
||||
if (type === 'ftp') Object.assign(base, { host: v('host'), port: Number(v('port') || 21), user: v('user'), password: v('password'), tls: v('tls') });
|
||||
if (type === 'gdrive') Object.assign(base, { token: v('token'), scope: v('scope') || 'drive.file', clientId: v('clientId') || undefined, clientSecret: v('clientSecret') || undefined });
|
||||
setBusy('new');
|
||||
try { const r = await api<{ id: string }>('POST', '/admin/backup/targets', base); setOpen(false); onChanged(); await test(r.id, v('name')); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(''); }
|
||||
}
|
||||
async function toggle(t: TargetInfo) { try { await api('PATCH', `/admin/backup/targets/${t.id}`, { enabled: !t.enabled }); onChanged(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
async function remove(id: string) { try { await api('DELETE', `/admin/backup/targets/${id}`); setDel(null); setMsg({ k: 'ok', t: 'Ziel entfernt. Bereits dort abgelegte Sicherungen bleiben unverändert.' }); onChanged(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
async function resetKey(id: string) { try { await api('POST', `/admin/backup/targets/${id}/reset-hostkey`); setMsg({ k: 'ok', t: 'Server-Schlüssel vergessen. Beim nächsten Test wird der aktuelle gemerkt.' }); onChanged(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row between"><h3 style={{ margin: 0 }}>Externe Ziele</h3>{canEdit && <button className="btn primary" aria-expanded={open} onClick={() => setOpen(!open)}>Ziel hinzufügen</button>}</div>
|
||||
{msg && <div style={{ marginTop: 12 }}><Alert kind={msg.k}>{msg.t}</Alert></div>}
|
||||
{targets.length === 0 ? <Empty title="Noch kein externes Ziel">Ohne externes Ziel liegen die Sicherungen nur auf diesem Server.</Empty> : (
|
||||
<div className="tablewrap"><table><thead><tr><th>Name</th><th>Typ und Ziel</th><th>Status</th>{canEdit && <th><span className="sr">Aktionen</span></th>}</tr></thead><tbody>
|
||||
{targets.map((t) => <tr key={t.id}><td>{t.name}{!t.enabled && <div><span className="badge lock"><span aria-hidden="true">⊘</span>Deaktiviert</span></div>}</td>
|
||||
<td><div>{TYPE[t.type]}</div><div className="small muted mono">{where(t)}</div>{t.type === 'sftp' && (t.hostKeyPinned ? <div className="small muted">Server-Schlüssel: <span className="mono">{t.hostKeyFingerprint ?? 'gemerkt'}</span></div> : <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Server-Schlüssel noch nicht gemerkt (beim Test)</span></div>)}</td>
|
||||
<td>{t.lastTestOk === null ? <span className="badge info"><span aria-hidden="true">○</span>Noch nicht getestet</span> : t.lastTestOk ? <span className="badge ok"><span aria-hidden="true">✓</span>Funktioniert</span> : <span className="badge err"><span aria-hidden="true">✕</span>Fehler</span>}
|
||||
{t.lastTestAt && <div className="small muted">{fmt(t.lastTestAt)}</div>}{t.lastTestOk === false && t.lastTestError && <div className="small" style={{ color: 'var(--err)' }}>{t.lastTestError}</div>}</td>
|
||||
{canEdit && <td>{del === t.id ? <div role="alertdialog" aria-label="Ziel entfernen"><p className="small" style={{ margin: '0 0 8px' }}>Ziel „{t.name}“ entfernen? Bereits abgelegte Sicherungen bleiben dort.</p><div className="row"><button className="btn small danger" onClick={() => remove(t.id)}>Ja, entfernen</button><button className="btn small" onClick={() => setDel(null)}>Abbrechen</button></div></div>
|
||||
: <div className="row" style={{ gap: 8 }}><button className="btn small" disabled={busy === t.id} onClick={() => test(t.id, t.name)}>{busy === t.id ? 'Teste …' : 'Testen'}</button><button className="btn small" onClick={() => toggle(t)}>{t.enabled ? 'Deaktivieren' : 'Aktivieren'}</button>
|
||||
{t.type === 'sftp' && t.hostKeyPinned && <button className="btn small" onClick={() => resetKey(t.id)}>Server-Schlüssel neu erfassen</button>}<button className="btn small danger" onClick={() => setDel(t.id)}>Entfernen</button></div>}</td>}</tr>)}</tbody></table></div>)}
|
||||
|
||||
{open && canEdit && <form onSubmit={create} style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||||
<h4 style={{ margin: '0 0 12px' }}>Neues Ziel</h4>
|
||||
<div className="cols"><Field id="t-type" label="Art des Ziels"><select id="t-type" value={type} onChange={(e) => setType(e.target.value as TargetInfo['type'])}><option value="sftp">SFTP (per SSH)</option><option value="ftp">FTP / FTPS</option><option value="gdrive">Google Drive</option><option value="local">Ordner / Netzlaufwerk (eingehängt)</option></select></Field>
|
||||
<Field id="t-name" label="Name"><input id="t-name" name="name" required maxLength={100} placeholder="z. B. NAS im Keller" /></Field></div>
|
||||
{(type === 'sftp' || type === 'ftp') && <div className="cols"><Field id="t-host" label="Server"><input id="t-host" name="host" required placeholder="nas.example.de oder 192.168.10.20" /></Field><Field id="t-port" label="Port"><input id="t-port" name="port" type="number" min={1} max={65535} defaultValue={type === 'sftp' ? 22 : 21} /></Field><Field id="t-user" label="Benutzer"><input id="t-user" name="user" required autoComplete="off" /></Field></div>}
|
||||
{type === 'sftp' && <><fieldset style={{ border: 0, padding: 0, margin: '0 0 12px' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 6 }}>Anmeldung</legend><div className="row"><label className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={auth === 'password'} onChange={() => setAuth('password')} />Passwort</label><label className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={auth === 'key'} onChange={() => setAuth('key')} />Privater Schlüssel (empfohlen)</label></div></fieldset>
|
||||
{auth === 'password' ? <Field id="t-pw" label="Passwort" hint="Wird verschlüsselt gespeichert und nie wieder angezeigt."><input id="t-pw" name="password" type="password" autoComplete="new-password" required /></Field>
|
||||
: <div className="cols"><Field id="t-key" label="Privater Schlüssel (PEM)" hint="Beginnt mit -----BEGIN … PRIVATE KEY-----. Der passende öffentliche Schlüssel muss auf dem Server hinterlegt sein."><textarea id="t-key" name="privateKey" rows={5} required spellCheck={false} style={{ width: '100%', font: 'inherit', padding: 10, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface)', color: 'var(--text)' }} /></Field><Field id="t-kp" label="Passphrase des Schlüssels (falls vorhanden)"><input id="t-kp" name="keyPassphrase" type="password" autoComplete="new-password" /></Field></div>}
|
||||
<p className="small muted">Der Server-Schlüssel wird beim ersten Test gemerkt (Fingerabdruck wird angezeigt). Danach wird ein anderer Server abgelehnt.</p></>}
|
||||
{type === 'ftp' && <div className="cols"><Field id="t-fpw" label="Passwort" hint="Wird verschlüsselt gespeichert."><input id="t-fpw" name="password" type="password" autoComplete="new-password" required /></Field>
|
||||
<Field id="t-tls" label="Verschlüsselung der Verbindung"><select id="t-tls" name="tls" defaultValue="explicit"><option value="explicit">FTPS (explizit, empfohlen)</option><option value="implicit">FTPS (implizit)</option><option value="none">Keine (Passwort im Klartext im Netz!)</option></select></Field></div>}
|
||||
{type === 'gdrive' && <><Alert kind="info">Google Drive braucht eine einmalige Freigabe. Führen Sie auf einem Rechner mit Browser diesen Befehl aus (rclone installiert) und fügen Sie den ausgegebenen Text (<span className="mono">{'{"access_token":…}'}</span>) unten ein:<br /><span className="mono" style={{ userSelect: 'all' }}>rclone authorize "drive" "eyJzY29wZSI6ImRyaXZlLmZpbGUifQ"</span><br />Es wird nur der Zugriff auf von rclone angelegte Dateien angefragt (Bereich drive.file).</Alert>
|
||||
<Field id="t-tok" label="Token (JSON)" hint="Wird verschlüsselt gespeichert und nie wieder angezeigt."><textarea id="t-tok" name="token" rows={4} required spellCheck={false} style={{ width: '100%', font: 'inherit', padding: 10, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface)', color: 'var(--text)' }} /></Field>
|
||||
<div className="cols"><Field id="t-cid" label="Eigene Client-ID (optional, empfohlen)"><input id="t-cid" name="clientId" autoComplete="off" /></Field><Field id="t-cs" label="Client-Secret (optional)"><input id="t-cs" name="clientSecret" type="password" autoComplete="new-password" /></Field></div></>}
|
||||
<Field id="t-path" label={type === 'local' ? 'Ordner (absoluter Pfad)' : 'Ordner auf dem Ziel'} hint={type === 'local' ? 'Der Ordner muss existieren, z. B. ein eingehängtes Netzlaufwerk unter /mnt/nas.' : 'Wird bei Bedarf angelegt, z. B. Backups/kundencenter'}><input id="t-path" name="path" required={type === 'local'} maxLength={400} placeholder={type === 'local' ? '/mnt/nas/kundencenter' : 'Backups/kundencenter'} /></Field>
|
||||
<div className="row"><button className="btn primary" type="submit" disabled={busy === 'new'}>{busy === 'new' ? 'Bitte warten …' : 'Speichern und testen'}</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div>
|
||||
</form>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/components/ConfigFields.tsx
Normal file
40
apps/web/src/components/ConfigFields.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Field } from '@/components/ui';
|
||||
|
||||
export interface CfgField { name: string; label: string; secret: boolean; required: boolean; placeholder: string; help: string; advanced: boolean; options: { value: string; label: string }[] | null }
|
||||
|
||||
/** Formularfelder eines Connectors: Pflichtfelder sichtbar, „erweiterte“ Einstellungen eingeklappt, Auswahlfelder statt Freitext. */
|
||||
export function ConfigFields({ fields, prefix, existing, hasSecrets }: { fields: CfgField[]; prefix: string; existing?: Record<string, unknown>; hasSecrets?: boolean }) {
|
||||
const [tls, setTls] = useState<{ ok: boolean; text: string } | null>(null); const [busy, setBusy] = useState(false);
|
||||
const cur = (n: string) => { const v = existing?.[n]; return v === undefined || v === null ? '' : String(v); };
|
||||
async function fetchFp(fieldId: string) {
|
||||
const baseEl = document.getElementById(`${prefix}baseUrl`) as HTMLInputElement | null; const url = baseEl?.value?.trim() || cur('baseUrl');
|
||||
if (!url) { setTls({ ok: false, text: 'Bitte zuerst die Adresse eintragen.' }); return; }
|
||||
setBusy(true); setTls(null);
|
||||
try {
|
||||
const r = await api<{ fingerprint: string; subject: string | null; issuer: string | null; validTo: string | null; trusted: boolean; selfSigned: boolean }>('POST', '/admin/connector-tls-check', { url });
|
||||
if (r.trusted) { setTls({ ok: true, text: `Das Zertifikat von ${r.subject ?? 'dem Server'} ist von einer öffentlichen Stelle bestätigt. Ein Fingerabdruck ist nicht nötig, das Feld kann leer bleiben.` }); return; }
|
||||
(document.getElementById(fieldId) as HTMLInputElement).value = r.fingerprint;
|
||||
setTls({ ok: true, text: `Fingerabdruck eingetragen (Zertifikat für ${r.subject ?? '?'}${r.selfSigned ? ', selbstsigniert' : ''}, gültig bis ${r.validTo ?? '?'}). Bitte einmal mit dem Zertifikat in KeyHelp abgleichen, dann speichern.` });
|
||||
} catch (e) { setTls({ ok: false, text: errMsg(e) }); } finally { setBusy(false); }
|
||||
}
|
||||
const one = (f: CfgField) => {
|
||||
const id = `${prefix}${f.name}`; const hint = [f.help, f.secret ? (hasSecrets ? 'Gespeichert. Nur neu eingeben, um zu ersetzen.' : 'Wird verschlüsselt gespeichert und nie wieder angezeigt.') : cur(f.name) && !f.options ? `Aktuell: ${cur(f.name)}` : ''].filter(Boolean).join(' ');
|
||||
return (<div key={f.name}>
|
||||
<Field id={id} label={f.label + (f.required && !existing ? ' *' : '')} hint={hint || undefined}>
|
||||
{f.options ? <select id={id} name={f.name} defaultValue={cur(f.name) || f.options[0]!.value}>{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
|
||||
: <input id={id} name={f.name} type={f.secret ? 'password' : 'text'} autoComplete="off" placeholder={f.placeholder} required={f.required && !existing} defaultValue={f.secret ? undefined : undefined} />}
|
||||
</Field>
|
||||
{f.name === 'tlsFingerprint' && <div className="row" style={{ marginTop: -8, marginBottom: 12 }}><button type="button" className="btn small" disabled={busy} onClick={() => fetchFp(id)}>{busy ? 'Wird geholt …' : 'Vom Server holen'}</button></div>}
|
||||
</div>);
|
||||
};
|
||||
const main = fields.filter((f) => !f.advanced); const adv = fields.filter((f) => f.advanced);
|
||||
return (<>
|
||||
<div className="cols">{main.map(one)}</div>
|
||||
{adv.length > 0 && <details style={{ margin: '8px 0 16px' }}><summary style={{ cursor: 'pointer', fontWeight: 600 }}>Erweiterte Einstellungen (optional)</summary>
|
||||
<div style={{ paddingTop: 12 }}><div className="cols">{adv.map(one)}</div>
|
||||
{tls && <div role="status" className={`alert ${tls.ok ? 'ok' : 'err'}`}>{tls.text}</div>}</div></details>}
|
||||
</>);
|
||||
}
|
||||
51
apps/web/src/components/DomainRecords.tsx
Normal file
51
apps/web/src/components/DomainRecords.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, eur, fmt } from '@/components/ui';
|
||||
|
||||
interface Rec { id: string; domain: string; tld: string; customer: string | null; customerNumber: string | null; source: string; termMonths: number | null; costNetCents: number | null; costGrossCents: number | null; setupCostCents: number; sellNetCents: number | null; taxBp: number; sellGrossCents: number | null; profitNetCents: number | null; procurement: 'open' | 'ordered' | 'external'; orderedAt: string | null; orderedRef: string | null; note: string | null }
|
||||
interface Res { id: string; name: string; type: string; orgId: string | null }
|
||||
const PROC = { open: 'Bei KCS zu bestellen', ordered: 'Bei KCS bestellt', external: 'Anderer Anbieter' } as const;
|
||||
const money = (c: number | null) => c === null ? '–' : eur(c);
|
||||
const csvNum = (c: number | null) => c === null ? '' : (c / 100).toFixed(2).replace('.', ',');
|
||||
const csvCell = (v: string | number | null) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
/** CSV für Excel (Semikolon, UTF-8 mit BOM): Bestellliste bzw. Gesamtaufstellung. */
|
||||
function download(rows: Rec[], name: string) {
|
||||
const head = ['Domain', 'Endung', 'Laufzeit (Monate)', 'Kunde', 'Kundennr.', 'EK brutto', 'EK netto', 'Setup EK brutto', 'VK brutto', 'VK netto', 'USt %', 'Gewinn netto', 'Status', 'bestellt am', 'Bestellnr. KCS', 'Notiz'];
|
||||
const lines = rows.map((r) => [r.domain, r.tld, r.termMonths, r.customer, r.customerNumber, csvNum(r.costGrossCents), csvNum(r.costNetCents), csvNum(r.setupCostCents), csvNum(r.sellGrossCents), csvNum(r.sellNetCents), r.taxBp / 100, csvNum(r.profitNetCents), PROC[r.procurement], r.orderedAt ? new Date(r.orderedAt).toLocaleDateString('de-DE') : '', r.orderedRef, r.note].map(csvCell).join(';'));
|
||||
const url = URL.createObjectURL(new Blob(['' + [head.map(csvCell).join(';'), ...lines].join('\r\n')], { type: 'text/csv;charset=utf-8' }));
|
||||
const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
}
|
||||
export function DomainRecords({ orgId }: { orgId?: string }) {
|
||||
const { can } = useSession(); const w = can('domains.write');
|
||||
const [list, setList] = useState<Rec[] | null>(null); const [status, setStatus] = useState(''); const [q, setQ] = useState(''); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
|
||||
const [res, setRes] = useState<Res[]>([]); const [ref, setRef] = useState<Record<string, string>>({});
|
||||
const load = useCallback(() => api<Rec[]>('GET', `/admin/domain-records?${new URLSearchParams({ ...(status ? { status } : {}), ...(q ? { q } : {}), ...(orgId ? { orgId } : {}) })}`).then((l) => { setList(l); setErr(''); }).catch((e) => setErr(errMsg(e))), [status, q, orgId]);
|
||||
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
|
||||
useEffect(() => { if (w) api<Res[]>('GET', '/resources').then((r) => setRes(r.filter((x) => x.type === 'hosting_account' && (!orgId || x.orgId === orgId)))).catch(() => undefined); }, [w, orgId]);
|
||||
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } };
|
||||
async function add(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const f = e.currentTarget; const d = String(new FormData(f).get('domain')); await run(() => api('POST', '/admin/domain-records', { domain: d, orgId: orgId ?? null }), `${d} erfasst.`); f.reset(); }
|
||||
async function fromRes(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const id = String(new FormData(e.currentTarget).get('res')); if (!id) return; await run(async () => { const r = await api<{ added: number; skipped: number; unpriced: number }>('POST', `/admin/domain-records/from-resource/${id}`); setOk(`${r.added} Domains übernommen, ${r.skipped} übersprungen (Subdomains, System-Domain, bereits erfasst)${r.unpriced ? `. Bei ${r.unpriced} fehlt die Endung in der Preisliste.` : '.'}`); }); }
|
||||
const sum = (f: (r: Rec) => number | null) => (list ?? []).reduce((s, r) => s + (f(r) ?? 0), 0);
|
||||
const open = (list ?? []).filter((r) => r.procurement === 'open');
|
||||
return (<>
|
||||
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||
{w && <div className="card"><div className="cols">
|
||||
<form onSubmit={add}><Field id={`nd${orgId ?? ''}`} label="Domain manuell erfassen"><input id={`nd${orgId ?? ''}`} name="domain" placeholder="beispiel.de" required autoComplete="off" /></Field><button className="btn primary" type="submit">Erfassen</button></form>
|
||||
<form onSubmit={fromRes}><Field id={`nr${orgId ?? ''}`} label="Domains eines Hosting-Kontos übernehmen" hint="Liest die Domains aus KeyHelp (ohne Subdomains)."><select id={`nr${orgId ?? ''}`} name="res" defaultValue=""><option value="">Konto wählen …</option>{res.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}</select></Field><button className="btn" type="submit">Übernehmen</button></form></div></div>}
|
||||
<div className="card">
|
||||
<div className="cols"><Field id={`q${orgId ?? ''}`} label="Suche"><input id={`q${orgId ?? ''}`} type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Domain" /></Field>
|
||||
<Field id={`st${orgId ?? ''}`} label="Status"><select id={`st${orgId ?? ''}`} value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option>{Object.entries(PROC).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field></div>
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Noch keine Domains erfasst">Erfassen Sie Domains manuell oder übernehmen Sie sie aus einem Hosting-Konto.</Empty> : <>
|
||||
<div className="tablewrap"><table><thead><tr><th>Domain</th>{!orgId && <th>Kunde</th>}<th>EK brutto</th><th>EK netto</th><th>VK brutto</th><th>VK netto</th><th>Gewinn netto</th><th>Status bei KCS</th>{w && <th></th>}</tr></thead><tbody>
|
||||
{list.map((r) => (<tr key={r.id}><td><span className="mono">{r.domain}</span>{r.termMonths && <div className="muted small">{r.termMonths} Mon.{r.setupCostCents ? ` · Setup ${eur(r.setupCostCents)}` : ''}</div>}{r.sellNetCents === null && <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{r.costGrossCents === null ? 'Endung nicht in Preisliste' : 'Aufschlag fehlt'}</span></div>}</td>
|
||||
{!orgId && <td>{r.customer ? <>{r.customer}<div className="muted small">{r.customerNumber}</div></> : <span className="muted small">–</span>}</td>}<td>{money(r.costGrossCents)}<div className="muted small">inkl. USt</div></td><td>{money(r.costNetCents)}</td><td><strong>{money(r.sellGrossCents)}</strong><div className="muted small">inkl. {r.taxBp / 100} % USt</div></td><td>{money(r.sellNetCents)}</td><td>{money(r.profitNetCents)}</td>
|
||||
<td>{w ? <><select aria-label={`Status ${r.domain}`} value={r.procurement} onChange={(e) => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { procurement: e.target.value }))}>{Object.entries(PROC).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select>
|
||||
{r.procurement === 'ordered' && <div className="row" style={{ marginTop: 4 }}><input aria-label="Bestellnummer bei KCS" placeholder="Bestellnr." style={{ width: 120 }} value={ref[r.id] ?? r.orderedRef ?? ''} onChange={(e) => setRef({ ...ref, [r.id]: e.target.value })} onBlur={() => ref[r.id] !== undefined && run(() => api('PATCH', `/admin/domain-records/${r.id}`, { orderedRef: ref[r.id] || null }))} />{r.orderedAt && <span className="muted small">{fmt(r.orderedAt)}</span>}</div>}</> : PROC[r.procurement]}</td>
|
||||
{w && <td><div className="row"><button className="btn small" title="Einkauf und Verkauf mit der aktuellen Preisliste neu berechnen" onClick={() => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { reprice: true }), `${r.domain} neu berechnet.`)}>Neu berechnen</button><button className="btn small" onClick={() => confirm(`${r.domain} aus der Aufstellung entfernen?`) && run(() => api('DELETE', `/admin/domain-records/${r.id}`))}>Entfernen</button></div></td>}</tr>))}
|
||||
<tr><th>Summe ({list.length})</th>{!orgId && <th></th>}<th>{eur(sum((r) => r.costGrossCents))}</th><th>{eur(sum((r) => r.costNetCents))}</th><th>{eur(sum((r) => r.sellGrossCents))}</th><th>{eur(sum((r) => r.sellNetCents))}</th><th>{eur(sum((r) => r.profitNetCents))}</th><th colSpan={w ? 2 : 1}></th></tr></tbody></table></div>
|
||||
<div className="row"><button className="btn primary" disabled={open.length === 0} onClick={() => download(open, `KCS-Bestellliste-${new Date().toISOString().slice(0, 10)}.csv`)}>Bestellliste für KCS (offen: {open.length})</button><button className="btn" onClick={() => download(list, `Domain-Aufstellung-${new Date().toISOString().slice(0, 10)}.csv`)}>Gesamte Aufstellung als CSV</button></div></>}
|
||||
<p className="muted small">Summen beziehen sich auf die angezeigte Auswahl. Preise sind Momentaufnahmen; „Neu berechnen“ übernimmt geänderte Preislisten oder Aufschläge.</p></div>
|
||||
</>);
|
||||
}
|
||||
116
apps/web/src/components/HostingPanel.tsx
Normal file
116
apps/web/src/components/HostingPanel.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { copyText } from '@/lib/clipboard';
|
||||
import { generatePassword, MIN_CHILD_PASSWORD } from '@/lib/password';
|
||||
import { Alert, Empty, Field, fmt } from '@/components/ui';
|
||||
|
||||
type Kind = 'domain' | 'email' | 'database' | 'ftp' | 'certificate';
|
||||
interface Child { id: string; kind: Kind; name: string; state: string; validUntil?: string | null; details: Record<string, any> }
|
||||
interface Kinds { kinds: { kind: Kind; canWrite: boolean }[]; panelLogin: boolean }
|
||||
const LABEL: Record<Kind, string> = { domain: 'Domains', email: 'E-Mail', database: 'Datenbanken', ftp: 'FTP', certificate: 'SSL-Zertifikate' };
|
||||
const STATE: Record<string, [string, string, string]> = { active: ['ok', '●', 'Aktiv'], disabled: ['lock', '⊘', 'Deaktiviert'], pending: ['info', '◐', 'Wird eingerichtet'], error: ['err', '✕', 'Fehler'], expired: ['warn', '▲', 'Abgelaufen'] };
|
||||
const bytes = (b?: number | null) => (b == null ? '–' : b < 1024 ** 2 ? `${Math.max(0, Math.round(b / 1024))} KB` : b < 1024 ** 3 ? `${(b / 1024 ** 2).toFixed(1)} MB` : `${(b / 1024 ** 3).toFixed(1)} GB`);
|
||||
const USAGE: Record<string, { label: string; bytes?: boolean }> = { disk_space: { label: 'Speicher', bytes: true }, traffic: { label: 'Traffic', bytes: true }, domains: { label: 'Domains' }, email_accounts: { label: 'Postfächer' }, databases: { label: 'Datenbanken' }, ftp_users: { label: 'FTP-Benutzer' }, subdomains: { label: 'Subdomains' } };
|
||||
|
||||
/** Nutzung als Balken (mit Text, nicht nur Farbe). */
|
||||
export function UsageBars({ usage, limits }: { usage: Record<string, unknown>; limits: Record<string, unknown> }) {
|
||||
const rows = Object.entries(USAGE).filter(([k]) => typeof usage[k] === 'number');
|
||||
if (!rows.length) return null;
|
||||
return (<div className="card"><h2>Nutzung</h2><div className="grid">{rows.map(([k, m]) => {
|
||||
const v = usage[k] as number; const max = limits[k] as number | null | undefined; const unlimited = max === null || max === undefined; const pct = unlimited || !max ? 0 : Math.min(100, Math.round((v / max) * 100)); const warn = !unlimited && pct >= 90;
|
||||
const f = (x: number) => (m.bytes ? bytes(x) : String(x));
|
||||
return (<div key={k}><div className="row between small"><strong>{m.label}</strong><span>{f(v)} / {unlimited ? 'unbegrenzt' : f(max as number)}{warn && <> <span className="badge warn"><span aria-hidden="true">▲</span>fast voll</span></>}</span></div>
|
||||
<div role="progressbar" aria-label={m.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={unlimited ? undefined : pct} aria-valuetext={unlimited ? 'unbegrenzt' : `${pct} Prozent`} style={{ height: 10, background: 'var(--surface-2)', borderRadius: 6, overflow: 'hidden', border: '1px solid var(--border)' }}><div style={{ width: `${unlimited ? 0 : pct}%`, height: '100%', background: warn ? 'var(--err)' : 'var(--accent)' }} /></div></div>);
|
||||
})}</div></div>);
|
||||
}
|
||||
|
||||
export function PanelLoginButton({ resourceId }: { resourceId: string }) {
|
||||
const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [url, setUrl] = useState('');
|
||||
async function go() {
|
||||
setBusy(true); setErr(''); setUrl('');
|
||||
try { const r = await api<{ url: string }>('POST', `/resources/${resourceId}/login`); const w = window.open(r.url, '_blank', 'noopener,noreferrer'); if (!w) setUrl(r.url); }
|
||||
catch (e) { setErr(errMsg(e)); } finally { setBusy(false); }
|
||||
}
|
||||
return (<div><button className="btn primary" onClick={go} disabled={busy}>{busy ? 'Bitte warten …' : 'Kundenpanel öffnen'}</button>
|
||||
{err && <div style={{ marginTop: 8 }}><Alert kind="err">{err}</Alert></div>}
|
||||
{url && <p className="small" style={{ marginTop: 8 }}>Der Browser hat das Fenster blockiert: <a href={url} target="_blank" rel="noopener noreferrer">Jetzt öffnen</a> (Link 60 Minuten gültig).</p>}
|
||||
<p className="small muted" style={{ marginTop: 8 }}>Sicherer Einmal-Link, 60 Minuten gültig. Jeder Aufruf wird protokolliert.</p></div>);
|
||||
}
|
||||
|
||||
async function waitJob(id: string, onState: (s: string) => void): Promise<{ status: string; error: string | null }> {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const j = await api<{ status: string; error: string | null }>('GET', `/jobs/${id}`); onState(j.status);
|
||||
if (['succeeded', 'failed', 'needs_review'].includes(j.status)) return j;
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
return { status: 'running', error: null };
|
||||
}
|
||||
const JOB_TEXT: Record<string, string> = { scheduled: 'Geplant …', running: 'Wird ausgeführt …', retrying: 'Wird wiederholt …', waiting_external: 'Wartet auf den Anbieter …' };
|
||||
|
||||
function PasswordField({ id, value, onChange, label = 'Passwort' }: { id: string; value: string; onChange: (v: string) => void; label?: string }) {
|
||||
const [show, setShow] = useState(false); const [copied, setCopied] = useState(false);
|
||||
return (<div><Field id={id} label={label} hint={`Mindestens ${MIN_CHILD_PASSWORD} Zeichen. Wird nur zur Ausführung verschlüsselt zwischengespeichert.`}><input id={id} type={show ? 'text' : 'password'} value={value} onChange={(e) => onChange(e.target.value)} autoComplete="new-password" minLength={MIN_CHILD_PASSWORD} maxLength={128} required /></Field>
|
||||
<div className="row" style={{ marginBottom: 12 }}><button type="button" className="btn small" onClick={() => { onChange(generatePassword()); setShow(true); setCopied(false); }}>Sicheres Passwort erzeugen</button>
|
||||
<button type="button" className="btn small" onClick={() => setShow(!show)} aria-pressed={show}>{show ? 'Verbergen' : 'Anzeigen'}</button>
|
||||
{value && <button type="button" className="btn small" onClick={async () => setCopied(await copyText(value))}>{copied ? 'Kopiert ✓' : 'Kopieren'}</button>}</div></div>);
|
||||
}
|
||||
|
||||
export function HostingPanel({ resourceId, canLogin }: { resourceId: string; canLogin: boolean }) {
|
||||
const [kinds, setKinds] = useState<Kinds | null>(null); const [kind, setKind] = useState<Kind>('domain'); const [rows, setRows] = useState<Child[] | null>(null); const [err, setErr] = useState('');
|
||||
const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'info'; t: string } | null>(null); const [adding, setAdding] = useState(false); const [del, setDel] = useState<string | null>(null); const [busy, setBusy] = useState(false); const [pw, setPw] = useState('');
|
||||
const [domains, setDomains] = useState<Child[]>([]);
|
||||
useEffect(() => { api<Kinds>('GET', `/resources/${resourceId}/children`).then((k) => { setKinds(k); if (k.kinds[0]) setKind(k.kinds[0].kind); }).catch((e) => setErr(errMsg(e))); }, [resourceId]);
|
||||
const load = useCallback(() => { setRows(null); setErr(''); api<Child[]>('GET', `/resources/${resourceId}/children/${kind}`).then(setRows).catch((e) => { setErr(errMsg(e)); setRows([]); }); }, [resourceId, kind]);
|
||||
useEffect(() => { if (kinds) load(); }, [kinds, load]);
|
||||
useEffect(() => { api<Child[]>('GET', `/resources/${resourceId}/children/domain`).then(setDomains).catch(() => setDomains([])); }, [resourceId]);
|
||||
const canWrite = kinds?.kinds.find((k) => k.kind === kind)?.canWrite ?? false;
|
||||
|
||||
async function submit(op: 'create' | 'update' | 'delete', data: Record<string, unknown>, opts: { id?: string; password?: string; okText: string }) {
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
const r = await api<{ jobId: string }>('POST', `/resources/${resourceId}/children/${kind}`, { op, id: opts.id, data, password: opts.password });
|
||||
setMsg({ k: 'info', t: 'Der Auftrag wurde angenommen …' });
|
||||
const j = await waitJob(r.jobId, (s) => setMsg({ k: 'info', t: JOB_TEXT[s] ?? 'Wird ausgeführt …' }));
|
||||
if (j.status === 'succeeded') { setMsg({ k: 'ok', t: opts.okText }); setAdding(false); setDel(null); setPw(''); load(); }
|
||||
else if (j.status === 'running') setMsg({ k: 'info', t: 'Der Vorgang läuft noch. Bitte in einigen Sekunden neu laden.' });
|
||||
else setMsg({ k: 'err', t: j.error ?? 'Der Vorgang ist fehlgeschlagen.' });
|
||||
} catch (e) { setMsg({ k: 'err', t: errMsg(e) }); } finally { setBusy(false); }
|
||||
}
|
||||
function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget); const g = (n: string) => ((f.get(n) as string) || '').trim();
|
||||
if (kind === 'domain') return submit('create', { domain: g('domain'), ...(g('php') ? { phpVersion: g('php') } : {}), letsEncrypt: f.get('le') === 'on', forceHttps: f.get('le') === 'on' }, { okText: 'Die Domain wurde angelegt.' });
|
||||
if (kind === 'email') return submit('create', { local: g('local'), domain: g('mdomain'), ...(g('size') ? { maxSizeBytes: Math.round(Number(g('size')) * 1024 * 1024) } : {}) }, { password: pw, okText: 'Das Postfach wurde angelegt.' });
|
||||
if (kind === 'database') return submit('create', { ...(g('name') ? { name: g('name') } : {}), description: g('desc') }, { password: pw, okText: 'Die Datenbank wurde angelegt.' });
|
||||
if (kind === 'ftp') return submit('create', { ...(g('user') ? { username: g('user') } : {}), home: g('home') || '/www/', description: g('desc') }, { password: pw, okText: 'Der FTP-Zugang wurde angelegt.' });
|
||||
}
|
||||
if (!kinds) return err ? <Alert kind="err">{err}</Alert> : null;
|
||||
return (<>
|
||||
{canLogin && <div className="card"><h2>Kundenpanel</h2><PanelLoginButton resourceId={resourceId} /></div>}
|
||||
<div className="card"><h2>Hosting-Ressourcen</h2>
|
||||
<div role="tablist" aria-label="Ressourcenarten" className="row" style={{ gap: 8, marginBottom: 16 }}>
|
||||
{kinds.kinds.map((k) => <button key={k.kind} role="tab" aria-selected={kind === k.kind} className="btn" style={kind === k.kind ? { background: 'var(--surface-2)', fontWeight: 600, borderColor: 'var(--accent)' } : undefined} onClick={() => { setKind(k.kind); setAdding(false); setMsg(null); setDel(null); }}>{LABEL[k.kind]}</button>)}</div>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{canWrite && kind !== 'certificate' && <div style={{ marginBottom: 12 }}><button className="btn primary" aria-expanded={adding} onClick={() => { setAdding(!adding); setPw(''); }}>{{ domain: 'Domain hinzufügen', email: 'Postfach anlegen', database: 'Datenbank anlegen', ftp: 'FTP-Zugang anlegen', certificate: '' }[kind]}</button></div>}
|
||||
{adding && canWrite && <form onSubmit={create} style={{ borderBottom: '1px solid var(--border)', marginBottom: 16, paddingBottom: 8 }}>
|
||||
{kind === 'domain' && <div className="cols"><Field id="hp-dom" label="Domainname"><input id="hp-dom" name="domain" required placeholder="beispiel.de oder www.beispiel.de" /></Field><Field id="hp-php" label="PHP-Version (leer = Standard)"><input id="hp-php" name="php" placeholder="z. B. 8.3" /></Field><label className="row" style={{ alignSelf: 'end', minHeight: 44 }}><input type="checkbox" name="le" defaultChecked style={{ width: 20, minHeight: 20 }} />Let’s Encrypt und HTTPS</label></div>}
|
||||
{kind === 'email' && <><div className="cols"><Field id="hp-loc" label="Postfach"><input id="hp-loc" name="local" required placeholder="info" /></Field><Field id="hp-md" label="Domain"><select id="hp-md" name="mdomain" required>{domains.filter((d) => d.details.emailDomain || true).map((d) => <option key={d.id} value={d.name}>{d.name}</option>)}</select></Field><Field id="hp-sz" label="Größe in MB (optional)"><input id="hp-sz" name="size" type="number" min={0} /></Field></div><PasswordField id="hp-pw" value={pw} onChange={setPw} /></>}
|
||||
{kind === 'database' && <><div className="cols"><Field id="hp-dn" label="Name (optional, sonst automatisch)" hint="Kleinbuchstaben, Ziffern, Unterstrich"><input id="hp-dn" name="name" pattern="[a-z0-9_]{1,64}" /></Field><Field id="hp-dd" label="Beschreibung"><input id="hp-dd" name="desc" maxLength={200} /></Field></div><PasswordField id="hp-pw" value={pw} onChange={setPw} /></>}
|
||||
{kind === 'ftp' && <><div className="cols"><Field id="hp-fu" label="Benutzername (optional, sonst automatisch)"><input id="hp-fu" name="user" pattern="[A-Za-z0-9._-]{1,32}" /></Field><Field id="hp-fh" label="Verzeichnis" hint="Beginnt mit /www/ oder /files"><input id="hp-fh" name="home" defaultValue="/www/" /></Field><Field id="hp-fd" label="Beschreibung"><input id="hp-fd" name="desc" maxLength={200} /></Field></div><PasswordField id="hp-pw" value={pw} onChange={setPw} /></>}
|
||||
<div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Anlegen'}</button><button className="btn" type="button" onClick={() => setAdding(false)}>Abbrechen</button></div></form>}
|
||||
{rows === null ? <p className="muted" role="status">Wird beim Anbieter gelesen …</p> : rows.length === 0 && !err ? <Empty title="Keine Einträge" /> : (
|
||||
<div className="tablewrap"><table><thead><tr><th>Name</th><th>Details</th><th>Status</th>{canWrite && <th><span className="sr">Aktionen</span></th>}</tr></thead><tbody>
|
||||
{rows.map((r) => { const [c, i, l] = STATE[r.state] ?? ['info', '○', r.state]; const d = r.details; return (<tr key={r.id}>
|
||||
<td className="mono">{r.name}</td>
|
||||
<td className="small">{r.kind === 'domain' && <>{d.subdomain ? 'Subdomain' : d.system ? 'System-Domain' : 'Hauptdomain'} · PHP {d.phpVersion} · {d.letsEncrypt ? 'Let’s Encrypt' : 'kein Let’s Encrypt'}{d.forceHttps ? ' · HTTPS erzwungen' : ''}</>}
|
||||
{r.kind === 'email' && <>{bytes(d.sizeBytes)} von {d.maxSizeBytes ? bytes(d.maxSizeBytes) : 'unbegrenzt'}{d.forwardings?.length ? ` · Weiterleitung an ${d.forwardings.join(', ')}` : ''}</>}
|
||||
{r.kind === 'database' && <>Benutzer <span className="mono">{d.user}</span> · {bytes(d.sizeBytes)}</>}
|
||||
{r.kind === 'ftp' && <>Verzeichnis <span className="mono">{d.home}</span></>}
|
||||
{r.kind === 'certificate' && <>{d.issuer ?? '–'} · gültig bis {r.validUntil ? fmt(r.validUntil) : '–'} · {(d.securedDomains ?? []).join(', ')}</>}</td>
|
||||
<td><span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span></td>
|
||||
{canWrite && <td>{del === r.id ? <div role="alertdialog" aria-label="Löschen bestätigen"><p className="small" style={{ margin: '0 0 8px' }}><strong>„{r.name}“ endgültig löschen?</strong>{r.kind !== 'domain' ? ' Die Daten gehen verloren.' : ' Zugehörige Inhalte können verloren gehen.'}</p><div className="row"><button className="btn small danger" disabled={busy} onClick={() => submit('delete', {}, { id: r.id, okText: 'Wurde gelöscht.' })}>Ja, löschen</button><button className="btn small" onClick={() => setDel(null)}>Abbrechen</button></div></div>
|
||||
: <div className="row" style={{ gap: 8 }}><button className="btn small danger" onClick={() => setDel(r.id)} disabled={busy || (r.kind === 'domain' && d.system)}>Löschen</button></div>}</td>}</tr>); })}</tbody></table></div>)}
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
43
apps/web/src/components/SecretField.tsx
Normal file
43
apps/web/src/components/SecretField.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { copyText } from '@/lib/clipboard';
|
||||
|
||||
/**
|
||||
* Lizenzschlüssel: standardmäßig unscharf (im Seiteninhalt steht nur ein Platzhalter), Klick lädt den echten Schlüssel,
|
||||
* daneben ein Kopieren-Button. Der Abruf erfolgt live beim Anbieter, wird protokolliert und nie gespeichert.
|
||||
*/
|
||||
export function SecretField({ resourceId, compact = false }: { resourceId: string; compact?: boolean }) {
|
||||
const [secret, setSecret] = useState<string | null>(null); const [left, setLeft] = useState(0); const [copied, setCopied] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState('');
|
||||
useEffect(() => { if (!secret) return; if (left <= 0) { setSecret(null); setCopied(false); return; } const t = setTimeout(() => setLeft(left - 1), 1000); return () => clearTimeout(t); }, [secret, left]);
|
||||
const fetchSecret = () => api<{ items: { label: string; value: string }[]; hideAfterSec: number }>('POST', `/resources/${resourceId}/reveal`);
|
||||
async function reveal() {
|
||||
if (secret || busy) return; setBusy(true); setErr('');
|
||||
try { const r = await fetchSecret(); setSecret(r.items[0]?.value ?? null); setLeft(r.hideAfterSec); setCopied(false); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
/** Kopieren nutzt den angezeigten Wert, sonst wird er kurz abgerufen und direkt kopiert, ohne ihn einzublenden. */
|
||||
async function copy() {
|
||||
if (busy) return; setBusy(true); setErr('');
|
||||
let v: string | null | undefined = secret;
|
||||
if (!v) { try { v = (await fetchSecret()).items[0]?.value; } catch (x) { setCopied(false); setErr(errMsg(x)); setBusy(false); return; } } // echter Abruffehler
|
||||
if (!v) { setErr('Kein Schlüssel vorhanden.'); setBusy(false); return; }
|
||||
const ok = await copyText(v);
|
||||
setCopied(ok);
|
||||
if (!ok) { setErr('Der Browser hat das Kopieren blockiert. Bitte auf den Schlüssel klicken (markiert ihn komplett) und mit Strg+C kopieren.'); if (!secret) { setSecret(v); setLeft(60); } } // Rückfall: zum manuellen Kopieren einblenden
|
||||
setBusy(false);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ alignItems: 'stretch', gap: 8, flexWrap: compact ? 'nowrap' : 'wrap' }}>
|
||||
<button type="button" onClick={reveal} disabled={busy && !secret} aria-label={secret ? 'Lizenzschlüssel (sichtbar)' : 'Lizenzschlüssel anzeigen'} className="mono"
|
||||
style={{ flex: '1 1 auto', minWidth: compact ? 260 : 280, textAlign: 'left', padding: compact ? '6px 10px' : '10px 14px', minHeight: compact ? 36 : 44, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface-2)', color: 'var(--text)',
|
||||
cursor: secret ? 'text' : 'pointer', fontSize: compact ? '.85rem' : '1.05rem', userSelect: secret ? 'all' : 'none', filter: secret ? 'none' : 'blur(6px)', transition: 'filter .15s' }}>
|
||||
{secret ?? 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'}
|
||||
</button>
|
||||
<button type="button" className={`btn${compact ? ' small' : ''}`} onClick={copy} disabled={busy}>{busy ? '…' : copied ? 'Kopiert ✓' : 'Kopieren'}</button>
|
||||
{secret && <button type="button" className={`btn${compact ? ' small' : ''}`} onClick={() => { setSecret(null); setCopied(false); }}>Verbergen</button>}
|
||||
</div>
|
||||
<div className="small muted" style={{ marginTop: 6 }} role="status">{err ? <span style={{ color: 'var(--err)' }}>{err}</span> : secret ? `Wird in ${left} Sekunden wieder verborgen. Bitte vertraulich behandeln.` : compact ? 'Klicken zum Anzeigen' : 'Zum Anzeigen auf das Feld klicken. Jeder Abruf wird protokolliert.'}{copied && !secret ? ' In die Zwischenablage kopiert.' : ''}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/components/ui.tsx
Normal file
53
apps/web/src/components/ui.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Einheitliches Statussystem: Text + Symbol, nie nur Farbe. */
|
||||
const MAP: Record<string, { cls: string; icon: string; label: string }> = {
|
||||
active: { cls: 'ok', icon: '●', label: 'Aktiv' }, invited: { cls: 'info', icon: '◐', label: 'Eingeladen' },
|
||||
disabled: { cls: 'lock', icon: '⊘', label: 'Gesperrt' }, suspended: { cls: 'lock', icon: '⊘', label: 'Gesperrt' },
|
||||
closed: { cls: 'lock', icon: '■', label: 'Beendet' }, warning: { cls: 'warn', icon: '▲', label: 'Warnung' }, error: { cls: 'err', icon: '✕', label: 'Gestört' },
|
||||
};
|
||||
export function Status({ value }: { value: string }) {
|
||||
const m = MAP[value] ?? { cls: 'info', icon: '○', label: value };
|
||||
return <span className={`badge ${m.cls}`}><span aria-hidden="true">{m.icon}</span>{m.label}</span>;
|
||||
}
|
||||
export const Alert = ({ kind, children }: { kind: 'err' | 'ok' | 'warn' | 'info'; children: ReactNode }) => <div className={`alert ${kind}`} role={kind === 'err' ? 'alert' : 'status'}>{children}</div>;
|
||||
export const Field = ({ id, label, children, hint }: { id: string; label: string; children: ReactNode; hint?: string }) => (
|
||||
<div className="field"><label htmlFor={id}>{label}</label>{children}{hint && <span className="muted small">{hint}</span>}</div>
|
||||
);
|
||||
export const Empty = ({ title, children }: { title: string; children?: ReactNode }) => <div className="empty"><h3>{title}</h3>{children}</div>;
|
||||
export const fmt = (d: string | Date) => new Date(d).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
export const ROLE_LABEL: Record<string, string> = { support: 'Support', accounting: 'Buchhaltung', admin: 'Administrator', superadmin: 'Superadministrator', owner: 'Inhaber', member: 'Mitglied' };
|
||||
|
||||
export const STATE: Record<string, { cls: string; icon: string; label: string }> = {
|
||||
active: { cls: 'ok', icon: '●', label: 'Aktiv' }, suspended: { cls: 'lock', icon: '⊘', label: 'Gesperrt' }, expired: { cls: 'warn', icon: '▲', label: 'Abgelaufen' },
|
||||
error: { cls: 'err', icon: '✕', label: 'Gestört' }, unknown: { cls: 'info', icon: '○', label: 'Unbekannt' },
|
||||
};
|
||||
export function ResState({ value }: { value: string }) { const m = STATE[value] ?? STATE.unknown!; return <span className={`badge ${m.cls}`}><span aria-hidden="true">{m.icon}</span>{m.label}</span>; }
|
||||
export const JOB_LABEL: Record<string, string> = { scheduled: 'Geplant', running: 'Wird ausgeführt', waiting_external: 'Wartet auf externes System', succeeded: 'Erfolgreich', partial: 'Teilweise erfolgreich', failed: 'Fehlgeschlagen', retrying: 'Wird wiederholt', needs_review: 'Manuelle Prüfung erforderlich' };
|
||||
export function JobStatus({ value }: { value: string }) {
|
||||
const cls = value === 'succeeded' ? 'ok' : value === 'failed' || value === 'needs_review' ? 'err' : value === 'retrying' || value === 'partial' ? 'warn' : 'info';
|
||||
const icon = cls === 'ok' ? '✓' : cls === 'err' ? '✕' : cls === 'warn' ? '▲' : '◐';
|
||||
return <span className={`badge ${cls}`}><span aria-hidden="true">{icon}</span>{JOB_LABEL[value] ?? value}</span>;
|
||||
}
|
||||
export const ACTION_LABEL: Record<string, string> = { suspend: 'Sperren', unsuspend: 'Entsperren', extend: 'Verlängern', 'panel.login': 'Panel-Login', 'domain.manage': 'Domains verwalten', 'email.manage': 'Postfächer verwalten', 'database.manage': 'Datenbanken verwalten', 'ftp.manage': 'FTP-Zugänge verwalten' };
|
||||
/** Aktionen, die einem Kunden je Produkt/Ressource freigegeben werden können. */
|
||||
export const CUSTOMER_ACTIONS = ['suspend', 'unsuspend', 'extend', 'panel.login', 'domain.manage', 'email.manage', 'database.manage', 'ftp.manage'] as const;
|
||||
|
||||
export const eur = (cents: number): string => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
||||
export const INTERVAL: Record<string, string> = { once: 'einmalig', monthly: 'monatlich', yearly: 'jährlich' };
|
||||
const ORDER: Record<string, [string, string, string]> = {
|
||||
pending_approval: ['warn', '◐', 'Wartet auf Freigabe'], approved: ['info', '◐', 'Freigegeben'], provisioning: ['info', '◐', 'Wird bereitgestellt'], completed: ['ok', '✓', 'Abgeschlossen'],
|
||||
failed: ['err', '✕', 'Fehlgeschlagen'], rejected: ['lock', '⊘', 'Abgelehnt'], cancelled: ['lock', '⊘', 'Storniert'],
|
||||
};
|
||||
const CONTRACT: Record<string, [string, string, string]> = {
|
||||
pending: ['info', '◐', 'In Vorbereitung'], active: ['ok', '●', 'Aktiv'], suspended: ['lock', '⊘', 'Gesperrt'], cancelled: ['lock', '■', 'Beendet'], expired: ['warn', '▲', 'Abgelaufen'], failed: ['err', '✕', 'Fehlgeschlagen'],
|
||||
};
|
||||
const badge = (m: Record<string, [string, string, string]>, v: string) => { const [c, i, l] = m[v] ?? ['info', '○', v]; return <span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span>; };
|
||||
export const OrderStatusBadge = ({ value }: { value: string }) => badge(ORDER, value);
|
||||
export const ContractStatusBadge = ({ value }: { value: string }) => badge(CONTRACT, value);
|
||||
export interface Price { interval: string; quantity: number; taxBp: number; discountBp: number; setup: { net: number; tax: number; gross: number }; recurring: { net: number; tax: number; gross: number } }
|
||||
/** Preisdarstellung: Privatkunden brutto, Geschäftskunden netto mit Brutto in Klammern. */
|
||||
export function PriceText({ p, priv }: { p: Price; priv: boolean }) {
|
||||
const f = (x: { net: number; gross: number }) => (priv ? eur(x.gross) + ' inkl. USt.' : `${eur(x.net)} netto (${eur(x.gross)} brutto)`);
|
||||
return <>{p.setup.net > 0 && <div>Einrichtung: {f(p.setup)}</div>}{(p.recurring.net > 0 || p.interval !== 'once') && <div>{INTERVAL[p.interval]}: {f(p.recurring)}</div>}{p.discountBp > 0 && <div className="small muted">inkl. {p.discountBp / 100} % Rabatt</div>}</>;
|
||||
}
|
||||
13
apps/web/src/lib/api.ts
Normal file
13
apps/web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
export class ApiError extends Error { constructor(public status: number, public code: string, message: string, public details?: { path: string; message: string }[]) { super(message); } }
|
||||
let csrf = '';
|
||||
export const setCsrf = (t: string) => { csrf = t; };
|
||||
|
||||
/** Einheitlicher API-Zugriff über den same-origin Proxy /api (Cookie, CSRF-Header, normalisierte Fehler). */
|
||||
export async function api<T = unknown>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, { method, credentials: 'same-origin', headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(csrf ? { 'x-csrf-token': csrf } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
|
||||
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;
|
||||
if (!res.ok) throw new ApiError(res.status, data?.error?.code ?? 'ERROR', data?.error?.message ?? 'Anfrage fehlgeschlagen', data?.error?.details);
|
||||
return data as T;
|
||||
}
|
||||
export const errMsg = (e: unknown): string => e instanceof ApiError ? (e.details?.length ? `${e.message}: ${e.details.map((d) => d.message).join(', ')}` : e.message) : 'Der Server ist nicht erreichbar. Bitte später erneut versuchen.';
|
||||
21
apps/web/src/lib/clipboard.ts
Normal file
21
apps/web/src/lib/clipboard.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* Text in die Zwischenablage kopieren. Die moderne Clipboard-API steht nur in sicheren Kontexten (HTTPS, localhost) zur Verfügung;
|
||||
* über unverschlüsseltes HTTP (z. B. im Heimnetz) greift der Rückweg über eine markierte Textfläche und execCommand.
|
||||
* Liefert true bei Erfolg. Der Text wird nirgends gespeichert; die temporäre Fläche wird sofort entfernt.
|
||||
*/
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; }
|
||||
} catch { /* weiter mit Rückweg */ }
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; ta.setAttribute('readonly', ''); ta.setAttribute('aria-hidden', 'true');
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none';
|
||||
document.body.appendChild(ta); ta.focus(); ta.select(); ta.setSelectionRange(0, text.length);
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
5
apps/web/src/lib/password.ts
Normal file
5
apps/web/src/lib/password.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
'use client';
|
||||
const ALPHABET = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_.!?#%+=';
|
||||
/** Zufallspasswort mit dem Zufallsgenerator des Browsers (ohne verwechselbare Zeichen). */
|
||||
export function generatePassword(len = 24): string { const b = new Uint32Array(len); crypto.getRandomValues(b); return Array.from(b, (n) => ALPHABET[n % ALPHABET.length]).join(''); }
|
||||
export const MIN_CHILD_PASSWORD = 12;
|
||||
24
apps/web/src/lib/session.tsx
Normal file
24
apps/web/src/lib/session.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { api, setCsrf, ApiError } from './api';
|
||||
|
||||
export interface Me {
|
||||
user: { id: string; email: string; name: string }; kind: 'customer' | 'staff'; staffRole: string | null; permissions: string[];
|
||||
pendingMfa: boolean; mfaEnrolled: boolean; mfaEnrollRequired: boolean; csrf: string;
|
||||
organizations: { id: string; name: string; customerNumber: string; role: string }[];
|
||||
}
|
||||
interface Ctx { me: Me | null; loading: boolean; reload: () => Promise<Me | null>; can: (p: string) => boolean }
|
||||
const C = createContext<Ctx>({ me: null, loading: true, reload: async () => null, can: () => false });
|
||||
export const useSession = () => useContext(C);
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const reload = useCallback(async () => {
|
||||
try { const m = await api<Me>('GET', '/auth/me'); setCsrf(m.csrf); setMe(m); return m; }
|
||||
catch (e) { if (!(e instanceof ApiError) || e.status !== 401) console.error(e); setMe(null); return null; }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
return <C.Provider value={{ me, loading, reload, can: (p) => !!me?.permissions.includes(p) }}>{children}</C.Provider>;
|
||||
}
|
||||
13
apps/web/src/lib/uuid.ts
Normal file
13
apps/web/src/lib/uuid.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Zufällige ID für Idempotenzschlüssel. `crypto.randomUUID` gibt es nur in sicheren Kontexten (HTTPS);
|
||||
* über unverschlüsseltes HTTP (Heimnetz) greift der Rückweg über `getRandomValues`, der überall verfügbar ist.
|
||||
*/
|
||||
export function uuid(): string {
|
||||
const c = typeof crypto !== 'undefined' ? crypto : undefined;
|
||||
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
||||
const b = new Uint8Array(16);
|
||||
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b); else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
|
||||
b[6] = (b[6]! & 0x0f) | 0x40; b[8] = (b[8]! & 0x3f) | 0x80; // Version 4
|
||||
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
41
apps/web/tsconfig.json
Normal file
41
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue