Show registered domains on hosting resource pages

The resource detail now lists the domain records linked to the hosting
account (term, registration status), so customers see the domains
included in their package. Prices and procurement state are staff-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-10-01 11:54:46 +02:00
parent dad9528295
commit 1178e9edfe
2 changed files with 15 additions and 3 deletions

View file

@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { randomUUID, createHash } from 'node:crypto'; import { randomUUID, createHash } from 'node:crypto';
import { domainToUnicode } from 'node:url';
import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors'; import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors';
import { ConnectorError, type ChildKind } from '@kc/connector-sdk'; import { ConnectorError, type ChildKind } from '@kc/connector-sdk';
import { decrypt, encrypt, randomToken } from '../../core/crypto.js'; import { decrypt, encrypt, randomToken } from '../../core/crypto.js';
@ -88,7 +89,10 @@ export const resourcesModule: KcModule = {
const staff = can(a.principal, 'resources.read'); const staff = can(a.principal, 'resources.read');
const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]); const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]);
const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {}); const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {});
return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), canResetPassword: canResetPassword(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) }; // Registrierte Domains aus der Domain-Aufstellung (Laufzeit/Registrierung), nicht die Live-Einträge im Panel; Preise nur für Personal
const domains = (await query('SELECT id, domain, term_months, procurement, sell_gross_cents FROM domain_records WHERE resource_id = ? ORDER BY domain', [id]))
.map((x) => ({ id: x.id, domain: domainToUnicode(x.domain) || x.domain, termMonths: x.term_months, registered: x.procurement !== 'open', ...(staff ? { procurement: x.procurement, sellGrossCents: x.sell_gross_cents } : {}) }));
return { ...view(r, staff), domains, limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), canResetPassword: canResetPassword(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) };
}); });
app.post('/resources/:id/actions', async (req, reply) => { app.post('/resources/:id/actions', async (req, reply) => {

View file

@ -4,13 +4,15 @@ import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api'; import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { ACTION_LABEL, Alert, Field, JobStatus, ResState, fmt, CUSTOMER_ACTIONS } from '@/components/ui'; import { ACTION_LABEL, Alert, Field, JobStatus, ResState, fmt, eur, CUSTOMER_ACTIONS } from '@/components/ui';
import { SecretField } from '@/components/SecretField'; import { SecretField } from '@/components/SecretField';
import { uuid } from '@/lib/uuid'; import { uuid } from '@/lib/uuid';
import { HostingPanel, UsageBars } from '@/components/HostingPanel'; 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; 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 }[] } canReveal?: boolean; hasChildren?: boolean; canLogin?: boolean; limits: Record<string, unknown>; usage: Record<string, unknown>; details: Record<string, any>; allowedActions: string[]; customerActions?: string[]; domains: DomainRec[]; jobs: { id: string; action: string; status: string; error: string | null; createdAt: string }[] }
interface DomainRec { id: string; domain: string; termMonths: number | null; registered: boolean; procurement?: string; sellGrossCents?: number | null }
const PROCUREMENT: Record<string, string> = { open: 'noch nicht bestellt', ordered: 'beim Registrar bestellt', external: 'extern verwaltet' };
const LIMIT_LABEL: Record<string, string> = { users: 'Benutzer', storageMb: 'Speicher (MB)' }; const LIMIT_LABEL: Record<string, string> = { users: 'Benutzer', storageMb: 'Speicher (MB)' };
interface PanelCreds { canReset: boolean; set: boolean; setAt: string | null } interface PanelCreds { canReset: boolean; set: boolean; setAt: string | null }
export default function Ressource() { export default function Ressource() {
@ -74,6 +76,12 @@ export default function Ressource() {
<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} <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> </dl>
<p className="small muted" style={{ marginTop: 16 }}>Stand: {fmt(d.syncedAt)}{staff && d.instance ? ` · Verbindung: ${d.instance}` : ''}</p></div> <p className="small muted" style={{ marginTop: 16 }}>Stand: {fmt(d.syncedAt)}{staff && d.instance ? ` · Verbindung: ${d.instance}` : ''}</p></div>
{d.domains.length > 0 && <div className="card"><h2>Domains</h2>
<div className="tablewrap"><table><thead><tr><th>Domain</th><th>Laufzeit</th><th>Status</th>{staff && <th>Preis (brutto)</th>}</tr></thead><tbody>
{d.domains.map((x) => <tr key={x.id}><td>{x.domain}</td><td>{x.termMonths ? `${x.termMonths} Monate` : '–'}</td>
<td>{staff ? PROCUREMENT[x.procurement ?? ''] ?? x.procurement : x.registered ? 'Registriert' : 'In Bearbeitung'}</td>
{staff && <td>{x.sellGrossCents != null ? eur(x.sellGrossCents) : '–'}</td>}</tr>)}</tbody></table></div>
{staff && <p className="small muted" style={{ marginTop: 8 }}><Link href="/admin/domains">Zur Domain-Aufstellung</Link></p>}</div>}
{d.type === 'hosting_account' && <UsageBars usage={d.usage} limits={d.limits} />} {d.type === 'hosting_account' && <UsageBars usage={d.usage} limits={d.limits} />}
{(d.hasChildren || d.canLogin) && <HostingPanel resourceId={d.id} canLogin={!!d.canLogin} />} {(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>} {d.canReveal && <div className="card"><h2>Lizenzschlüssel</h2><SecretField resourceId={d.id} /></div>}