KeyHelp: Panel-Passwort neu vergeben und verschlüsselt hinterlegen (verblurrt wie Lizenzschlüssel)

This commit is contained in:
Kundencenter 2026-09-27 21:35:15 +02:00
parent 3a2864ee76
commit 6fec6277f5
9 changed files with 136 additions and 20 deletions

View file

@ -12,13 +12,23 @@ 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)' };
interface PanelCreds { canReset: boolean; set: boolean; setAt: string | null }
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]);
const [creds, setCreds] = useState<PanelCreds | null>(null); const [credsBusy, setCredsBusy] = useState(false); const [credsConfirm, setCredsConfirm] = useState(false);
const load = useCallback(() => Promise.all([
api<Det>('GET', `/resources/${id}`).then(setD),
api<PanelCreds>('GET', `/resources/${id}/panel-credentials`).then(setCreds).catch(() => undefined),
]).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); }, [load]);
async function resetPanelPassword() {
setCredsBusy(true); setCredsConfirm(false);
try { await api('POST', `/resources/${id}/panel-credentials/reset`); setMsg({ k: 'ok', t: 'Wird gesetzt … das kann einen Moment dauern.' }); void load(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setCredsBusy(false); }
}
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));
@ -61,6 +71,17 @@ export default function Ressource() {
{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>}
{creds?.canReset && <div className="card"><h2>Panel-Zugangsdaten</h2>
<p className="muted small">Das bestehende Passwort ist nicht auslesbar. Hier wird bei Bedarf ein neues, zufälliges Passwort erzeugt und beim Anbieter gesetzt — das alte Passwort wird damit ungültig.</p>
{creds.set ? (<>
<SecretField resourceId={d.id} url={`/resources/${d.id}/panel-credentials/reveal`} label="Panel-Passwort" placeholder="••••••••••••••••••" />
<p className="muted small" style={{ marginTop: 8 }}>Zuletzt vergeben: {creds.setAt ? fmt(creds.setAt) : '–'}</p>
</>) : <p className="muted small">Es wurde noch kein Passwort über das Kundencenter vergeben.</p>}
{credsConfirm ? (
<div role="alertdialog" aria-labelledby="cd-pw" style={{ marginTop: 12 }}><p id="cd-pw"><strong>Neues Passwort setzen?</strong> Das {creds.set ? 'bisherige, über das Kundencenter vergebene' : 'aktuelle'} Passwort wird damit ungültig.</p>
<div className="row"><button className="btn primary" disabled={credsBusy} onClick={resetPanelPassword}>Ja, neu vergeben</button><button className="btn" onClick={() => setCredsConfirm(false)}>Abbrechen</button></div></div>
) : <button className="btn" style={{ marginTop: 12 }} onClick={() => setCredsConfirm(true)}>{creds.set ? 'Neues Passwort vergeben' : 'Passwort vergeben'}</button>}
</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>

View file

@ -4,13 +4,13 @@ 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.
* Geheimwert auf Abruf: standardmäßig unscharf (im Seiteninhalt steht nur ein Platzhalter), Klick lädt den echten Wert,
* daneben ein Kopieren-Button. Für Lizenzschlüssel (live beim Anbieter, nie gespeichert) und Panel-Passwörter (bei uns verschlüsselt gespeichert) genutzt.
*/
export function SecretField({ resourceId, compact = false }: { resourceId: string; compact?: boolean }) {
export function SecretField({ resourceId, compact = false, url, label = 'Lizenzschlüssel', placeholder = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }: { resourceId: string; compact?: boolean; url?: string; label?: string; placeholder?: string }) {
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`);
const fetchSecret = () => api<{ items: { label: string; value: string }[]; hideAfterSec: number }>('POST', url ?? `/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); }
@ -29,10 +29,10 @@ export function SecretField({ resourceId, compact = false }: { resourceId: strin
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"
<button type="button" onClick={reveal} disabled={busy && !secret} aria-label={secret ? `${label} (sichtbar)` : `${label} 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'}
{secret ?? placeholder}
</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>}

View file

@ -29,7 +29,7 @@ export function JobStatus({ value }: { value: string }) {
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' };
export const ACTION_LABEL: Record<string, string> = { suspend: 'Sperren', unsuspend: 'Entsperren', extend: 'Verlängern', reset_password: 'Zugangsdaten neu vergeben', '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;