KC@FlessingLabs 1.0.0: Testmodus ohne Lizenz, Installationsanleitung, Nutzungsbedingungen

Testmodus (ohne gültige Lizenz): 1 Personal-Konto, 2 Kunden; Discord-Bot,
E-Mail-Posteingang (IMAP), DATEV-Export und Backups auf externe Ziele nur mit
Lizenz. Mit Lizenz gelten deren Grenzen und Module (Lizenz ohne Modulliste =
voller Umfang). Bestehende Daten bleiben bei Ablauf vollständig nutzbar, nur
Neuanlagen über die Grenzen und die Zusatzfunktionen pausieren.

- Edition zentral in @kc/platform/license (getEdition, hasFeature,
  assertCustomerCapacity, assertStaffCapacity), auch in der CLI durchgesetzt
- Einstellungen → Lizenz zeigt die Edition, Hinweisbanner im Testmodus
- README mit Installation, Update und Betrieb; systemd-Units unter ops/systemd
- .env.example bereinigt (Kommentare nicht mehr hinter Werten, veraltete
  SMTP/Discord-Variablen entfernt; beides wird in der Oberfläche eingestellt)
- LICENSE.md: Nutzungsbedingungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-10-02 13:31:19 +02:00
parent fdd51b18e2
commit 17267b7c05
26 changed files with 368 additions and 73 deletions

View file

@ -9,6 +9,7 @@ interface Status {
trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null;
customerCount: number;
edition: { mode: 'licensed' | 'trial'; reason: string | null; customerLimit: number | null; staffLimit: number | null; staffCount: number; allFeatures: { key: string; label: string; enabled: boolean }[] };
}
const SOURCE_LABEL: Record<Status['source'], string> = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' };
const MODULE_LABEL: Record<string, string> = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' };
@ -38,13 +39,21 @@ export default function LizenzPage() {
<h2>Lizenz</h2>
<p className="muted">Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{!s.configured && <Alert kind="warn">Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.</Alert>}
{s.edition.mode === 'trial' && <Alert kind="warn"><strong>Testmodus</strong> ({s.edition.reason}): höchstens {s.edition.staffLimit} Personal-Konto und {s.edition.customerLimit} Kunden; {s.edition.allFeatures.filter((f) => !f.enabled).map((f) => f.label).join(', ')} nur mit Lizenz. Bestehende Daten bleiben vollständig nutzbar.</Alert>}
{s.configured && s.source === 'unchecked' && <Alert kind="info">Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.</Alert>}
{s.configured && s.source !== 'unchecked' && !s.valid && <Alert kind="err">Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.</Alert>}
{s.valid && s.customerLimit !== null && s.customerCount >= s.customerLimit && <Alert kind="err">Kundengrenze erreicht ({s.customerCount}/{s.customerLimit}). Neue Kunden können erst nach einem Upgrade angelegt werden.</Alert>}
{s.valid && s.customerLimit !== null && s.customerCount < s.customerLimit && s.customerCount >= s.customerLimit * 0.9 && <Alert kind="warn">Kundengrenze bald erreicht ({s.customerCount}/{s.customerLimit}). Ein Upgrade lohnt sich bald.</Alert>}
{s.valid && s.trial && s.expiresAt && new Date(s.expiresAt).getTime() - Date.now() < 7 * 86400000 && <Alert kind="warn">Testzeitraum endet bald ({fmt(s.expiresAt)}). Danach sind neue Kunden und kommerzielle Aktionen pausiert, bis ein Plan gewählt wird.</Alert>}
<div className="card">
<h3>Edition: {s.edition.mode === 'licensed' ? 'Lizenziert' : 'Testmodus'}</h3>
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
<div><dt className="small muted">Personal-Konten</dt><dd>{s.edition.staffCount}{s.edition.staffLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.staffLimit}`}</dd></div>
<div><dt className="small muted">Kunden</dt><dd>{s.customerCount}{s.edition.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.customerLimit}`}</dd></div>
{s.edition.allFeatures.map((f) => <div key={f.key}><dt className="small muted">{f.label}</dt><dd>{f.enabled ? <span className="badge ok">enthalten</span> : <span className="badge lock">nur mit Lizenz</span>}</dd></div>)}
</dl>
</div>
<div className="card">
<h3>Status</h3>
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>

View file

@ -9,6 +9,8 @@ import { ThemeToggle } from '@/components/ThemeToggle';
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 [trial, setTrial] = useState<string | null>(null);
useEffect(() => { if (me?.kind === 'staff' && can('license.read')) api<{ edition: { mode: string; staffLimit: number | null; customerLimit: number | null } }>('GET', '/admin/license/status').then((s) => setTrial(s.edition.mode === 'trial' ? `Testmodus: höchstens ${s.edition.staffLimit} Personal-Konto und ${s.edition.customerLimit} Kunden, ohne Discord-Bot, E-Mail-Posteingang, DATEV-Export und externe Backups.` : null)).catch(() => undefined); }, [me, can]);
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]);
@ -53,7 +55,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
)}
<div className="shell">
<div className="topbar"><strong>Kundencenter</strong><ThemeToggle compact /></div>
{nav}<main id="main" className="main">{children}</main>
{nav}<main id="main" className="main">{trial && path !== '/einstellungen/lizenz' && <div className="alert warn" role="status" style={{ marginBottom: 16 }}>{trial} <Link href="/einstellungen/lizenz">Lizenz hinterlegen</Link></div>}{children}</main>
{!enrollNeeded && <nav className="bottomnav" aria-label="Hauptnavigation (mobil)">
{bottomLink('/dashboard', 'Übersicht')}
{(me.kind === 'customer' || can('resources.read')) && bottomLink('/ressourcen', 'Ressourcen')}