Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
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>}</>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue