feat(customers): Impersonation ("als Kunde ansehen") mit Kennzeichnung und Audit
Umsetzung von Ticket #35: Superadmin/Admin kann auf der Kundenseite "Als Kunde ansehen" wählen und sieht das Kundencenter zeitlich begrenzt (60 min) genau so, wie der Kunde es sieht - mit sichtbarem Banner und jederzeitigem "Zurück zur Verwaltung". Technisch keine zweite Anmeldung: die bestehende Staff-Sitzung wird um Impersonation-Felder erweitert (Migration 036). loadAuth() schaltet den Principal währenddessen echt auf kind='customer' mit einer Mitgliedschaft (Rolle "owner") in genau dieser Organisation um - eine tatsächliche Rechteeinschränkung, keine bloß andere Oberfläche: staff-only-Endpunkte sind währenddessen wirklich nicht mehr erreichbar (can() liefert für kind='customer' grundsätzlich false), und canInOrg-geschützte Routen funktionieren unverändert für genau diese eine Organisation. Die echte Staff-Identität (AuthContext.user) bleibt für das Audit unverändert erhalten. - POST /admin/customers/:id/impersonate (Recht customers.impersonate, nur admin/superadmin): Grund Pflichtfeld, setzt die Sitzung, protokolliert customer.impersonate.start mit beiden Identitäten (actorId = echter Staff-Nutzer, orgId = Kunde). - loadAuth() erkennt eine abgelaufene Impersonation automatisch, setzt die Sitzung zurück (kein harter Logout) und protokolliert customer.impersonate.end mit reason "expired". - POST /auth/impersonate/stop: manuelles Beenden, reason "manual". - Web: Banner in (app)/layout.tsx mit Grund und "Zurück"-Button; Formular mit Grund-Eingabe auf der Kundenseite. Gegen die echte Produktionsdatenbank end-to-end verifiziert: Start/Stopp, staff-only-Endpunkt während Impersonation korrekt mit 403 abgelehnt, eigene Organisation lesbar (200), fremde Organisation weiterhin 404 (keine Existenz verraten), künstlich abgelaufene Sitzung fällt automatisch auf die Staff-Identität zurück, Audit-Einträge zeigen durchgehend beide Identitäten. Erfüllt die im Ticket vorgegebenen Testschritte vollständig. Testsuite 58/59 (vorbestehende, unabhängige Flakiness in orders.test.ts). Audit-Kette und Backup+Wiederherstellungstest danach weiterhin grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c52996b559
commit
2ed9697ca6
7 changed files with 98 additions and 11 deletions
|
|
@ -4,12 +4,15 @@ import { one, query, run } from './db.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { randomToken, sha256, safeEqual } from './crypto.js';
|
import { randomToken, sha256, safeEqual } from './crypto.js';
|
||||||
import { forbidden, unauthorized } from './errors.js';
|
import { forbidden, unauthorized } from './errors.js';
|
||||||
|
import { audit } from './audit.js';
|
||||||
import { can, type Principal, type OrgRole, type StaffRole } from './policy.js';
|
import { can, type Principal, type OrgRole, type StaffRole } from './policy.js';
|
||||||
|
|
||||||
export const COOKIE = 'kc_session';
|
export const COOKIE = 'kc_session';
|
||||||
const SESSION_HOURS = 12;
|
const SESSION_HOURS = 12;
|
||||||
const IDLE_MINUTES = 120;
|
const IDLE_MINUTES = 120;
|
||||||
|
export const IMPERSONATION_MINUTES = 60;
|
||||||
|
|
||||||
|
export interface Impersonating { orgId: string; orgName: string; customerNumber: string; reason: string; startedAt: string; expiresAt: string }
|
||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
csrf: string;
|
csrf: string;
|
||||||
|
|
@ -17,6 +20,10 @@ export interface AuthContext {
|
||||||
mfaEnrolled: boolean;
|
mfaEnrolled: boolean;
|
||||||
user: { id: string; email: string; name: string };
|
user: { id: string; email: string; name: string };
|
||||||
principal: Principal;
|
principal: Principal;
|
||||||
|
/** Gesetzt, während ein Staff-Mitglied "als Kunde" unterwegs ist (siehe customers-Modul, /admin/customers/:id/impersonate).
|
||||||
|
* principal ist in diesem Fall absichtlich auf 'customer' mit Mitgliedschaft in genau dieser Organisation umgeschaltet -
|
||||||
|
* echte Rechteeinschränkung, nicht nur eine andere Oberfläche. user bleibt die echte Staff-Identität (fürs Audit). */
|
||||||
|
impersonating: Impersonating | null;
|
||||||
}
|
}
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest { auth?: AuthContext; correlationId: string }
|
interface FastifyRequest { auth?: AuthContext; correlationId: string }
|
||||||
|
|
@ -39,7 +46,8 @@ async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
|
||||||
const token = req.cookies[COOKIE];
|
const token = req.cookies[COOKIE];
|
||||||
if (!token) return undefined;
|
if (!token) return undefined;
|
||||||
const s = await one(
|
const s = await one(
|
||||||
`SELECT s.id, s.csrf_token, s.pending_mfa, u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
|
`SELECT s.id, s.csrf_token, s.pending_mfa, s.impersonating_org_id, s.impersonation_reason, s.impersonation_started_at, s.impersonation_expires_at,
|
||||||
|
u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
|
||||||
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa
|
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa
|
||||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3)
|
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3)
|
||||||
|
|
@ -47,12 +55,30 @@ async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
|
||||||
[sha256(token), IDLE_MINUTES],
|
[sha256(token), IDLE_MINUTES],
|
||||||
);
|
);
|
||||||
if (!s || s.status !== 'active') return undefined;
|
if (!s || s.status !== 'active') return undefined;
|
||||||
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
|
|
||||||
await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]);
|
await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]);
|
||||||
|
|
||||||
|
let principal: Principal = { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: [] };
|
||||||
|
let impersonating: Impersonating | null = null;
|
||||||
|
if (s.impersonating_org_id) {
|
||||||
|
if (new Date(s.impersonation_expires_at as Date) <= new Date()) {
|
||||||
|
// Abgelaufen: Sitzung fällt automatisch auf die echte Staff-Identität zurück, kein harter Logout.
|
||||||
|
await run('UPDATE sessions SET impersonating_org_id = NULL, impersonation_reason = NULL, impersonation_started_at = NULL, impersonation_expires_at = NULL WHERE id = ?', [s.id]);
|
||||||
|
await audit({ actorType: 'user', actorId: s.uid, orgId: s.impersonating_org_id, action: 'customer.impersonate.end', resourceType: 'organization', resourceId: s.impersonating_org_id, after: { reason: 'expired' } }).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
const org = await one('SELECT name, customer_number FROM organizations WHERE id = ?', [s.impersonating_org_id]);
|
||||||
|
if (org) {
|
||||||
|
principal = { userId: s.uid, kind: 'customer', staffRole: null, memberships: [{ orgId: s.impersonating_org_id, role: 'owner' }] };
|
||||||
|
impersonating = { orgId: s.impersonating_org_id, orgName: org.name, customerNumber: org.customer_number, reason: s.impersonation_reason, startedAt: s.impersonation_started_at, expiresAt: s.impersonation_expires_at };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!impersonating) {
|
||||||
|
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
|
||||||
|
principal.memberships = ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole }));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0,
|
sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0,
|
||||||
user: { id: s.uid, email: s.email, name: s.name },
|
user: { id: s.uid, email: s.email, name: s.name }, principal, impersonating,
|
||||||
principal: { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })) },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { one, query, run, tx } from '../../core/db.js';
|
import { one, query, run, tx } from '../../core/db.js';
|
||||||
import { audit } from '../../core/audit.js';
|
import { audit } from '../../core/audit.js';
|
||||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
import { clientIp, requireAuth, requirePermission, IMPERSONATION_MINUTES } from '../../core/auth.js';
|
||||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||||
import { assertCustomerCapacity } from '../../core/license.js';
|
import { assertCustomerCapacity } from '../../core/license.js';
|
||||||
import { canInOrg } from '../../core/policy.js';
|
import { canInOrg } from '../../core/policy.js';
|
||||||
|
|
@ -60,6 +60,9 @@ async function nextCustomerNumber(c: Parameters<typeof one>[2]): Promise<string>
|
||||||
|
|
||||||
export const customersModule: KcModule = {
|
export const customersModule: KcModule = {
|
||||||
name: 'customers',
|
name: 'customers',
|
||||||
|
permissions: {
|
||||||
|
staff: { admin: ['customers.impersonate'], superadmin: ['customers.impersonate'] },
|
||||||
|
},
|
||||||
register(app: FastifyInstance) {
|
register(app: FastifyInstance) {
|
||||||
// ---- Admin: Kunden -----------------------------------------------------
|
// ---- Admin: Kunden -----------------------------------------------------
|
||||||
app.get('/admin/customers', async (req) => {
|
app.get('/admin/customers', async (req) => {
|
||||||
|
|
@ -108,6 +111,20 @@ export const customersModule: KcModule = {
|
||||||
return loadOrg(id);
|
return loadOrg(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** "Als Kunde ansehen": schaltet die eigene Sitzung zeitlich begrenzt (IMPERSONATION_MINUTES) auf die
|
||||||
|
* Sicht dieses Kunden um (echte Rechteeinschränkung, nicht nur eine andere Oberfläche). Admin/Superadmin
|
||||||
|
* only; Grund ist Pflicht und wird protokolliert. Lässt sich nicht verschachteln (erfordert customers.read,
|
||||||
|
* das während einer laufenden Impersonation nicht mehr greift). */
|
||||||
|
app.post('/admin/customers/:id/impersonate', async (req) => {
|
||||||
|
const a = requirePermission(req, 'customers.impersonate');
|
||||||
|
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const b = z.object({ reason: z.string().trim().min(3).max(300) }).parse(req.body);
|
||||||
|
const org = await one('SELECT id, name, customer_number FROM organizations WHERE id = ?', [id]); if (!org) throw notFound();
|
||||||
|
await run('UPDATE sessions SET impersonating_org_id = ?, impersonation_reason = ?, impersonation_started_at = UTC_TIMESTAMP(3), impersonation_expires_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE) WHERE id = ?', [id, b.reason, IMPERSONATION_MINUTES, a.sessionId]);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'customer.impersonate.start', resourceType: 'organization', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { reason: b.reason, minutes: IMPERSONATION_MINUTES } });
|
||||||
|
return { ok: true, orgName: org.name, customerNumber: org.customer_number, expiresInMinutes: IMPERSONATION_MINUTES };
|
||||||
|
});
|
||||||
|
|
||||||
/** Begrüßungsmail: Zugang (neuer Einladungslink oder Login-Link, falls schon aktiv), aktuelle Produkte, kurze Anleitung, Discord-Hinweis. */
|
/** Begrüßungsmail: Zugang (neuer Einladungslink oder Login-Link, falls schon aktiv), aktuelle Produkte, kurze Anleitung, Discord-Hinweis. */
|
||||||
app.post('/admin/customers/:id/welcome-mail', async (req) => {
|
app.post('/admin/customers/:id/welcome-mail', async (req) => {
|
||||||
const a = requirePermission(req, 'customers.write');
|
const a = requirePermission(req, 'customers.write');
|
||||||
|
|
|
||||||
|
|
@ -93,16 +93,30 @@ export const identityModule: KcModule = {
|
||||||
|
|
||||||
app.get('/auth/me', async (req) => {
|
app.get('/auth/me', async (req) => {
|
||||||
const a = requireAuth(req, { allowPendingMfa: true, allowUnenrolledStaff: true });
|
const a = requireAuth(req, { allowPendingMfa: true, allowUnenrolledStaff: true });
|
||||||
const orgs = a.principal.memberships.length
|
// Während einer Impersonation stammt die Mitgliedschaft nicht aus der echten memberships-Tabelle
|
||||||
|
// (der Staff-Nutzer ist dort nicht wirklich Mitglied) - direkt aus dem Impersonation-Kontext aufbauen.
|
||||||
|
const orgs = a.impersonating
|
||||||
|
? [{ id: a.impersonating.orgId, name: a.impersonating.orgName, customer_number: a.impersonating.customerNumber, role: 'owner' }]
|
||||||
|
: a.principal.memberships.length
|
||||||
? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id])
|
? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id])
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
user: a.user, kind: a.principal.kind, staffRole: a.principal.staffRole, permissions: staffPermissions(a.principal.staffRole),
|
user: a.user, kind: a.principal.kind, staffRole: a.principal.staffRole, permissions: staffPermissions(a.principal.staffRole),
|
||||||
pendingMfa: a.pendingMfa, mfaEnrolled: a.mfaEnrolled, mfaEnrollRequired: a.principal.kind === 'staff' && !a.mfaEnrolled,
|
pendingMfa: a.pendingMfa, mfaEnrolled: a.mfaEnrolled, mfaEnrollRequired: a.principal.kind === 'staff' && !a.mfaEnrolled,
|
||||||
organizations: orgs.map((o) => ({ id: o.id, name: o.name, customerNumber: o.customer_number, role: o.role })), csrf: a.csrf,
|
organizations: orgs.map((o) => ({ id: o.id, name: o.name, customerNumber: o.customer_number, role: o.role })), csrf: a.csrf,
|
||||||
|
impersonating: a.impersonating,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Beendet die eigene Impersonation-Sitzung (siehe POST /admin/customers/:id/impersonate), egal ob abgelaufen oder nicht. */
|
||||||
|
app.post('/auth/impersonate/stop', async (req) => {
|
||||||
|
const a = requireAuth(req, { allowUnenrolledStaff: true });
|
||||||
|
if (!a.impersonating) return { ok: true };
|
||||||
|
await run('UPDATE sessions SET impersonating_org_id = NULL, impersonation_reason = NULL, impersonation_started_at = NULL, impersonation_expires_at = NULL WHERE id = ?', [a.sessionId]);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: a.impersonating.orgId, action: 'customer.impersonate.end', resourceType: 'organization', resourceId: a.impersonating.orgId, correlationId: req.correlationId, ip: clientIp(req), after: { reason: 'manual' } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
// ---- MFA-Einrichtung ---------------------------------------------------
|
// ---- MFA-Einrichtung ---------------------------------------------------
|
||||||
app.post('/auth/mfa/setup', async (req) => {
|
app.post('/auth/mfa/setup', async (req) => {
|
||||||
const a = requireAuth(req, { allowUnenrolledStaff: true });
|
const a = requireAuth(req, { allowUnenrolledStaff: true });
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } 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 { SecretField } from '@/components/SecretField';
|
import { SecretField } from '@/components/SecretField';
|
||||||
|
|
@ -14,9 +14,10 @@ interface CRow { id: string; number: string; status: string; productName: string
|
||||||
interface ORow { id: string; number: string; status: string; createdAt: string; items: { snapshot: { name: string } }[] }
|
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 }
|
interface RRow { id: string; name: string; state: string; validUntil: string | null; stale: boolean; canReveal?: boolean }
|
||||||
export default function Kunde() {
|
export default function Kunde() {
|
||||||
const { id } = useParams<{ id: string }>(); const { can } = useSession();
|
const { id } = useParams<{ id: string }>(); const { can, reload } = useSession(); const r = useRouter();
|
||||||
const [contracts, setContracts] = useState<CRow[] | null>(null); const [orders, setOrders] = useState<ORow[] | null>(null); const [ress, setRess] = useState<RRow[] | null>(null);
|
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 [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 [impersonateOpen, setImpersonateOpen] = useState(false); const [impersonateBusy, setImpersonateBusy] = useState(false);
|
||||||
const load = useCallback(() => api<Org>('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
|
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]);
|
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>;
|
if (!o) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||||
|
|
@ -37,6 +38,13 @@ export default function Kunde() {
|
||||||
async function setStatus(status: string) {
|
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) }); }
|
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) }); }
|
||||||
}
|
}
|
||||||
|
async function impersonate(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setImpersonateBusy(true); const f = new FormData(e.currentTarget);
|
||||||
|
try {
|
||||||
|
await api('POST', `/admin/customers/${id}/impersonate`, { reason: String(f.get('reason') ?? '').trim() });
|
||||||
|
await reload(); r.push('/dashboard');
|
||||||
|
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); setImpersonateBusy(false); }
|
||||||
|
}
|
||||||
const [welcomeBusy, setWelcomeBusy] = useState(false);
|
const [welcomeBusy, setWelcomeBusy] = useState(false);
|
||||||
async function sendWelcome() {
|
async function sendWelcome() {
|
||||||
setWelcomeBusy(true);
|
setWelcomeBusy(true);
|
||||||
|
|
@ -45,9 +53,15 @@ export default function Kunde() {
|
||||||
}
|
}
|
||||||
return (<>
|
return (<>
|
||||||
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
|
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
|
||||||
<div className="row between"><h1>{o.name}</h1><Status value={o.status} /></div>
|
<div className="row between"><h1>{o.name}</h1><div className="row">{can('customers.impersonate') && <button className="btn" onClick={() => setImpersonateOpen(!impersonateOpen)} aria-expanded={impersonateOpen}>Als Kunde ansehen</button>}<Status value={o.status} /></div></div>
|
||||||
<p className="muted"><span className="mono">{o.customerNumber}</span> · <span className="badge info">{o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}</span></p>
|
<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>}
|
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||||
|
{impersonateOpen && <form className="card" onSubmit={impersonate} role="alertdialog" aria-labelledby="imp-h">
|
||||||
|
<h2 id="imp-h">Als Kunde ansehen</h2>
|
||||||
|
<p className="small muted">Schaltet deine Sitzung für 60 Minuten auf die Sicht dieses Kunden um (sichtbarer Banner, jederzeit zurück möglich). Wird protokolliert.</p>
|
||||||
|
<Field id="reason" label="Grund" hint="Pflichtfeld, erscheint im Audit-Protokoll"><input id="reason" name="reason" required minLength={3} maxLength={300} autoFocus /></Field>
|
||||||
|
<div className="row"><button className="btn primary" type="submit" disabled={impersonateBusy}>{impersonateBusy ? '…' : 'Ansicht wechseln'}</button><button className="btn" type="button" onClick={() => setImpersonateOpen(false)}>Abbrechen</button></div>
|
||||||
|
</form>}
|
||||||
<form className="card" onSubmit={save}><h2>Stammdaten & Rechnungsanschrift</h2>
|
<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>
|
<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>}
|
{o.customerType === 'business' && <Field id="company" label="Firma (Rechnung)"><input id="company" name="company" defaultValue={o.billing.company ?? ''} disabled={!w} /></Field>}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
<details className="group" open={inGroup(hrefs)}><summary>{title}</summary><div className="items">{items}</div></details>
|
<details className="group" open={inGroup(hrefs)}><summary>{title}</summary><div className="items">{items}</div></details>
|
||||||
);
|
);
|
||||||
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
|
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
|
||||||
|
async function stopImpersonation() { const orgId = me!.impersonating!.orgId; await api('POST', '/auth/impersonate/stop'); await reload(); r.replace(`/admin/kunden/${orgId}`); }
|
||||||
const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
|
const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
|
||||||
const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][];
|
const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][];
|
||||||
const nav = (
|
const nav = (
|
||||||
|
|
@ -44,6 +45,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
);
|
);
|
||||||
const bottomLink = (href: string, label: string) => <Link href={href} aria-current={path === href || path.startsWith(href + '/') ? 'page' : undefined}>{label}</Link>;
|
const bottomLink = (href: string, label: string) => <Link href={href} aria-current={path === href || path.startsWith(href + '/') ? 'page' : undefined}>{label}</Link>;
|
||||||
return (<div className="shell">
|
return (<div className="shell">
|
||||||
|
{me.impersonating && (
|
||||||
|
<div role="status" style={{ position: 'sticky', top: 0, zIndex: 100, background: 'var(--warn)', color: '#1a1200', padding: '8px 16px', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>👁 Ansicht als Kunde: <strong>{me.impersonating.orgName}</strong> ({me.impersonating.customerNumber}) · Grund: {me.impersonating.reason}</span>
|
||||||
|
<button className="btn small" onClick={() => void stopImpersonation()}>Zurück zur Verwaltung</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="topbar"><strong>Kundencenter</strong><ThemeToggle compact /></div>
|
<div className="topbar"><strong>Kundencenter</strong><ThemeToggle compact /></div>
|
||||||
{nav}<main id="main" className="main">{children}</main>
|
{nav}<main id="main" className="main">{children}</main>
|
||||||
{!enrollNeeded && <nav className="bottomnav" aria-label="Hauptnavigation (mobil)">
|
{!enrollNeeded && <nav className="bottomnav" aria-label="Hauptnavigation (mobil)">
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
import { api, setCsrf, ApiError } from './api';
|
import { api, setCsrf, ApiError } from './api';
|
||||||
|
|
||||||
|
export interface Impersonating { orgId: string; orgName: string; customerNumber: string; reason: string; startedAt: string; expiresAt: string }
|
||||||
export interface Me {
|
export interface Me {
|
||||||
user: { id: string; email: string; name: string }; kind: 'customer' | 'staff'; staffRole: string | null; permissions: string[];
|
user: { id: string; email: string; name: string }; kind: 'customer' | 'staff'; staffRole: string | null; permissions: string[];
|
||||||
pendingMfa: boolean; mfaEnrolled: boolean; mfaEnrollRequired: boolean; csrf: string;
|
pendingMfa: boolean; mfaEnrolled: boolean; mfaEnrollRequired: boolean; csrf: string;
|
||||||
organizations: { id: string; name: string; customerNumber: string; role: string }[];
|
organizations: { id: string; name: string; customerNumber: string; role: string }[];
|
||||||
|
impersonating: Impersonating | null;
|
||||||
}
|
}
|
||||||
interface Ctx { me: Me | null; loading: boolean; reload: () => Promise<Me | null>; can: (p: string) => boolean }
|
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 });
|
const C = createContext<Ctx>({ me: null, loading: true, reload: async () => null, can: () => false });
|
||||||
|
|
|
||||||
7
migrations/036_impersonation.sql
Normal file
7
migrations/036_impersonation.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Support-/Admin-Impersonation ("als Kunde ansehen"): zeitlich begrenzt, mit Grund, auf der bestehenden
|
||||||
|
-- Sitzung des Staff-Nutzers (kein zweiter Login nötig, "zurück" ist einfach die Sitzung zurücksetzen).
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN impersonating_org_id CHAR(36) NULL,
|
||||||
|
ADD COLUMN impersonation_reason VARCHAR(300) NULL,
|
||||||
|
ADD COLUMN impersonation_started_at DATETIME(3) NULL,
|
||||||
|
ADD COLUMN impersonation_expires_at DATETIME(3) NULL;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue